diff --git a/src/VersionInfo.php b/src/VersionInfo.php index 55495a959..93e593133 100644 --- a/src/VersionInfo.php +++ b/src/VersionInfo.php @@ -31,8 +31,8 @@ use function str_repeat; final class VersionInfo{ public const NAME = "PocketMine-MP"; - public const BASE_VERSION = "5.34.0"; - public const IS_DEVELOPMENT_BUILD = false; + public const BASE_VERSION = "5.34.1"; + public const IS_DEVELOPMENT_BUILD = true; public const BUILD_CHANNEL = "stable"; /** diff --git a/src/block/Cauldron.php b/src/block/Cauldron.php index c44da1682..d45ffebdb 100644 --- a/src/block/Cauldron.php +++ b/src/block/Cauldron.php @@ -82,7 +82,7 @@ final class Cauldron extends Transparent{ $this->fill(FillableCauldron::MAX_FILL_LEVEL, VanillaBlocks::LAVA_CAULDRON(), $item, VanillaItems::BUCKET(), $returnedItems); }elseif($item->getTypeId() === ItemTypeIds::POWDER_SNOW_BUCKET){ //TODO: powder snow cauldron - }elseif($item instanceof Potion || $item instanceof SplashPotion){ //TODO: lingering potion + }elseif($item instanceof Potion || $item instanceof SplashPotion){ if($item->getType() === PotionType::WATER){ $this->fill(WaterCauldron::WATER_BOTTLE_FILL_AMOUNT, VanillaBlocks::WATER_CAULDRON(), $item, VanillaItems::GLASS_BOTTLE(), $returnedItems); }else{ diff --git a/src/block/WaterCauldron.php b/src/block/WaterCauldron.php index 60bdc132e..9faf1c514 100644 --- a/src/block/WaterCauldron.php +++ b/src/block/WaterCauldron.php @@ -124,7 +124,7 @@ final class WaterCauldron extends FillableCauldron{ $world->addSound($this->position->add(0.5, 0.5, 0.5), new CauldronAddDyeSound()); $item->pop(); - }elseif($item instanceof Potion || $item instanceof SplashPotion){ //TODO: lingering potion + }elseif($item instanceof Potion || $item instanceof SplashPotion){ if($item->getType() === PotionType::WATER){ $this->setCustomWaterColor(null)->addFillLevels(self::WATER_BOTTLE_FILL_AMOUNT, $item, VanillaItems::GLASS_BOTTLE(), $returnedItems); }else{ diff --git a/src/block/tile/Campfire.php b/src/block/tile/Campfire.php index dc8066d31..0569908e2 100644 --- a/src/block/tile/Campfire.php +++ b/src/block/tile/Campfire.php @@ -94,6 +94,7 @@ class Campfire extends Spawnable implements ContainerTile{ $listeners = $this->inventory->getListeners()->toArray(); $this->inventory->getListeners()->remove(...$listeners); //prevent any events being fired by initialization + $baseErrorContext = "Campfire ($this->position)"; foreach([ [0, self::TAG_FIRST_INPUT_ITEM, self::TAG_FIRST_COOKING_TIME], [1, self::TAG_SECOND_INPUT_ITEM, self::TAG_SECOND_COOKING_TIME], @@ -101,7 +102,7 @@ class Campfire extends Spawnable implements ContainerTile{ [3, self::TAG_FOURTH_INPUT_ITEM, self::TAG_FOURTH_COOKING_TIME], ] as [$slot, $itemTag, $cookingTimeTag]){ if(($tag = $nbt->getTag($itemTag)) instanceof CompoundTag){ - $items[$slot] = Item::nbtDeserialize($tag); + $items[$slot] = Item::safeNbtDeserialize($tag, "$baseErrorContext slot $slot"); } if(($tag = $nbt->getTag($cookingTimeTag)) instanceof IntTag){ $this->cookingTimes[$slot] = $tag->getValue(); diff --git a/src/block/tile/Cauldron.php b/src/block/tile/Cauldron.php index d10f97e14..53d2ae389 100644 --- a/src/block/tile/Cauldron.php +++ b/src/block/tile/Cauldron.php @@ -76,7 +76,6 @@ final class Cauldron extends Spawnable{ default => throw new AssumptionFailedError("Unexpected potion item type") }); - //TODO: lingering potion $type = $this->potionItem instanceof Potion || $this->potionItem instanceof SplashPotion ? $this->potionItem->getType() : null; $nbt->setShort(self::TAG_POTION_ID, $type === null ? self::POTION_ID_NONE : PotionTypeIdMap::getInstance()->toId($type)); @@ -96,7 +95,7 @@ final class Cauldron extends Spawnable{ $this->potionItem = match($containerType){ self::POTION_CONTAINER_TYPE_NORMAL => VanillaItems::POTION()->setType($potionType), self::POTION_CONTAINER_TYPE_SPLASH => VanillaItems::SPLASH_POTION()->setType($potionType), - self::POTION_CONTAINER_TYPE_LINGERING => throw new SavedDataLoadingException("Not implemented"), + self::POTION_CONTAINER_TYPE_LINGERING => VanillaItems::LINGERING_POTION()->setType($potionType), default => throw new SavedDataLoadingException("Invalid potion container type ID $containerType") }; }else{ @@ -115,7 +114,6 @@ final class Cauldron extends Spawnable{ default => throw new AssumptionFailedError("Unexpected potion item type") }); - //TODO: lingering potion $type = $this->potionItem instanceof Potion || $this->potionItem instanceof SplashPotion ? $this->potionItem->getType() : null; $nbt->setShort(self::TAG_POTION_ID, $type === null ? self::POTION_ID_NONE : PotionTypeIdMap::getInstance()->toId($type)); diff --git a/src/block/tile/ChiseledBookshelf.php b/src/block/tile/ChiseledBookshelf.php index 4e4ae4a0f..f274ee543 100644 --- a/src/block/tile/ChiseledBookshelf.php +++ b/src/block/tile/ChiseledBookshelf.php @@ -26,7 +26,6 @@ namespace pocketmine\block\tile; use pocketmine\block\utils\ChiseledBookshelfSlot; use pocketmine\data\bedrock\item\SavedItemData; use pocketmine\data\bedrock\item\SavedItemStackData; -use pocketmine\data\SavedDataLoadingException; use pocketmine\inventory\SimpleInventory; use pocketmine\item\Item; use pocketmine\math\Vector3; @@ -99,18 +98,13 @@ class ChiseledBookshelf extends Tile implements ContainerTile{ $inventory->getListeners()->remove(...$listeners); //prevent any events being fired by initialization $newContents = []; + $errorLogContext = "ChiseledBookshelf ($this->position)"; foreach($inventoryTag as $slot => $itemNBT){ - try{ - $count = $itemNBT->getByte(SavedItemStackData::TAG_COUNT); - if($count === 0){ - continue; - } - $newContents[$slot] = Item::nbtDeserialize($itemNBT); - }catch(SavedDataLoadingException $e){ - //TODO: not the best solution - \GlobalLogger::get()->logException($e); + $count = $itemNBT->getByte(SavedItemStackData::TAG_COUNT); + if($count === 0){ continue; } + $newContents[$slot] = Item::safeNbtDeserialize($itemNBT, "$errorLogContext slot $slot"); } $inventory->setContents($newContents); diff --git a/src/block/tile/ContainerTileTrait.php b/src/block/tile/ContainerTileTrait.php index d5febdbf7..c579e84b5 100644 --- a/src/block/tile/ContainerTileTrait.php +++ b/src/block/tile/ContainerTileTrait.php @@ -24,7 +24,6 @@ declare(strict_types=1); namespace pocketmine\block\tile; use pocketmine\data\bedrock\item\SavedItemStackData; -use pocketmine\data\SavedDataLoadingException; use pocketmine\inventory\Inventory; use pocketmine\item\Item; use pocketmine\nbt\NBT; @@ -56,14 +55,10 @@ trait ContainerTileTrait{ $inventory->getListeners()->remove(...$listeners); //prevent any events being fired by initialization $newContents = []; + $errorLogContext = "Container (" . $this->getPosition() . ")"; foreach($inventoryTag as $itemNBT){ - try{ - $newContents[$itemNBT->getByte(SavedItemStackData::TAG_SLOT)] = Item::nbtDeserialize($itemNBT); - }catch(SavedDataLoadingException $e){ - //TODO: not the best solution - \GlobalLogger::get()->logException($e); - continue; - } + $slotId = $itemNBT->getByte(SavedItemStackData::TAG_SLOT); + $newContents[$slotId] = Item::safeNbtDeserialize($itemNBT, "$errorLogContext slot $slotId"); } $inventory->setContents($newContents); diff --git a/src/block/tile/ItemFrame.php b/src/block/tile/ItemFrame.php index 7d003770e..d69f533e3 100644 --- a/src/block/tile/ItemFrame.php +++ b/src/block/tile/ItemFrame.php @@ -51,7 +51,7 @@ class ItemFrame extends Spawnable{ public function readSaveData(CompoundTag $nbt) : void{ if(($itemTag = $nbt->getCompoundTag(self::TAG_ITEM)) !== null){ - $this->item = Item::nbtDeserialize($itemTag); + $this->item = Item::safeNbtDeserialize($itemTag, "ItemFrame ($this->position) framed item"); } if($nbt->getTag(self::TAG_ITEM_ROTATION) instanceof FloatTag){ $this->itemRotation = (int) ($nbt->getFloat(self::TAG_ITEM_ROTATION, $this->itemRotation * 45) / 45); diff --git a/src/block/tile/Jukebox.php b/src/block/tile/Jukebox.php index 54acd60ee..cc4448c32 100644 --- a/src/block/tile/Jukebox.php +++ b/src/block/tile/Jukebox.php @@ -44,7 +44,7 @@ class Jukebox extends Spawnable{ public function readSaveData(CompoundTag $nbt) : void{ if(($tag = $nbt->getCompoundTag(self::TAG_RECORD)) !== null){ - $record = Item::nbtDeserialize($tag); + $record = Item::safeNbtDeserialize($tag, "Jukebox ($this->position) record"); if($record instanceof Record){ $this->record = $record; } diff --git a/src/block/tile/Lectern.php b/src/block/tile/Lectern.php index 37e79b10e..2ce586252 100644 --- a/src/block/tile/Lectern.php +++ b/src/block/tile/Lectern.php @@ -45,7 +45,7 @@ class Lectern extends Spawnable{ public function readSaveData(CompoundTag $nbt) : void{ $this->viewedPage = $nbt->getInt(self::TAG_PAGE, 0); if(($itemTag = $nbt->getCompoundTag(self::TAG_BOOK)) !== null){ - $book = Item::nbtDeserialize($itemTag); + $book = Item::safeNbtDeserialize($itemTag, "Lectern ($this->position) book"); if($book instanceof WritableBookBase && !$book->isNull()){ $this->book = $book; } diff --git a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php index bb4c61509..4d9b751fe 100644 --- a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php +++ b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php @@ -519,6 +519,14 @@ final class ItemSerializerDeserializerRegistrar{ }, fn(GoatHorn $item) => GoatHornTypeIdMap::getInstance()->toId($item->getHornType()) ); + $this->map1to1ItemWithMeta( + Ids::LINGERING_POTION, + Items::LINGERING_POTION(), + function(SplashPotion $item, int $meta) : void{ + $item->setType(PotionTypeIdMap::getInstance()->fromId($meta) ?? throw new ItemTypeDeserializeException("Unknown potion type ID $meta")); + }, + fn(SplashPotion $item) => PotionTypeIdMap::getInstance()->toId($item->getType()) + ); $this->map1to1ItemWithMeta( Ids::MEDICINE, Items::MEDICINE(), diff --git a/src/entity/EntityFactory.php b/src/entity/EntityFactory.php index a5acb0c61..09543421f 100644 --- a/src/entity/EntityFactory.php +++ b/src/entity/EntityFactory.php @@ -29,6 +29,7 @@ use pocketmine\data\bedrock\PotionTypeIdMap; use pocketmine\data\bedrock\PotionTypeIds; use pocketmine\data\SavedDataLoadingException; use pocketmine\entity\EntityDataHelper as Helper; +use pocketmine\entity\object\AreaEffectCloud; use pocketmine\entity\object\EndCrystal; use pocketmine\entity\object\ExperienceOrb; use pocketmine\entity\object\FallingBlock; @@ -84,6 +85,10 @@ final class EntityFactory{ //define legacy save IDs first - use them for saving for maximum compatibility with Minecraft PC //TODO: index them by version to allow proper multi-save compatibility + $this->register(AreaEffectCloud::class, function(World $world, CompoundTag $nbt) : AreaEffectCloud{ + return new AreaEffectCloud(Helper::parseLocation($nbt, $world), $nbt); + }, ['AreaEffectCloud', 'minecraft:area_effect_cloud']); + $this->register(Arrow::class, function(World $world, CompoundTag $nbt) : Arrow{ return new Arrow(Helper::parseLocation($nbt, $world), null, $nbt->getByte(Arrow::TAG_CRIT, 0) === 1, $nbt); }, ['Arrow', 'minecraft:arrow']); diff --git a/src/entity/Human.php b/src/entity/Human.php index 37252d8c5..3dcb760d4 100644 --- a/src/entity/Human.php +++ b/src/entity/Human.php @@ -331,9 +331,11 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ if($slot >= 0 && $slot < 9){ //Hotbar //Old hotbar saving stuff, ignore it }elseif($slot >= 100 && $slot < 104){ //Armor - $armorInventoryItems[$slot - 100] = Item::nbtDeserialize($item); + $armorSlot = $slot - 100; + $armorInventoryItems[$armorSlot] = Item::safeNbtDeserialize($item, "Human armor slot $armorSlot"); }elseif($slot >= 9 && $slot < $this->inventory->getSize() + 9){ - $inventoryItems[$slot - 9] = Item::nbtDeserialize($item); + $inventorySlot = $slot - 9; + $inventoryItems[$inventorySlot] = Item::safeNbtDeserialize($item, "Human inventory slot $inventorySlot"); } } @@ -342,7 +344,7 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ } $offHand = $nbt->getCompoundTag(self::TAG_OFF_HAND_ITEM); if($offHand !== null){ - $this->setOffHandItem(Item::nbtDeserialize($offHand)); + $this->setOffHandItem(Item::safeNbtDeserialize($offHand, "Human off-hand item")); } $this->offHandInventory->getListeners()->add(CallbackInventoryListener::onAnyChange(fn() => NetworkBroadcastUtils::broadcastEntityEvent( $this->getViewers(), @@ -353,8 +355,9 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ if($enderChestInventoryTag !== null){ $enderChestInventoryItems = []; - foreach($enderChestInventoryTag as $i => $item){ - $enderChestInventoryItems[$item->getByte(SavedItemStackData::TAG_SLOT)] = Item::nbtDeserialize($item); + foreach($enderChestInventoryTag as $item){ + $slot = $item->getByte(SavedItemStackData::TAG_SLOT); + $enderChestInventoryItems[$slot] = Item::safeNbtDeserialize($item, "Human ender chest slot $slot"); } self::populateInventoryFromListTag($this->enderInventory, $enderChestInventoryItems); } diff --git a/src/entity/effect/EffectCollection.php b/src/entity/effect/EffectCollection.php new file mode 100644 index 000000000..ed1abf65b --- /dev/null +++ b/src/entity/effect/EffectCollection.php @@ -0,0 +1,223 @@ + + */ + protected ObjectSet $effectAddHooks; + + /** + * @var \Closure[]|ObjectSet + * @phpstan-var ObjectSet<\Closure(EffectInstance) : void> + */ + protected ObjectSet $effectRemoveHooks; + + protected Color $bubbleColor; + + protected bool $onlyAmbientEffects = false; + + /** + * Validates whether an effect will be used for bubbles color calculation. + * + * @phpstan-var \Closure(EffectInstance) : bool + */ + protected \Closure $effectFilterForBubbles; + + public function __construct(){ + $this->bubbleColor = new Color(0, 0, 0, 0); + $this->effectAddHooks = new ObjectSet(); + $this->effectRemoveHooks = new ObjectSet(); + + $this->setEffectFilterForBubbles(static fn(EffectInstance $e) : bool => $e->isVisible() && $e->getType()->hasBubbles()); + } + + /** + * Returns all the effects in the collection, indexed by spl_object_id of the effect type. + * @return EffectInstance[] + */ + public function all() : array{ + return $this->effects; + } + + /** + * Removes all effects. + */ + public function clear() : void{ + foreach($this->effects as $effect){ + $this->remove($effect->getType()); + } + } + + /** + * Removes the effect with the specified ID. + */ + public function remove(Effect $effectType) : void{ + $index = spl_object_id($effectType); + if(isset($this->effects[$index])){ + $effect = $this->effects[$index]; + + unset($this->effects[$index]); + foreach($this->effectRemoveHooks as $hook){ + $hook($effect); + } + + $this->recalculateEffectColor(); + } + } + + /** + * Returns the effect instance active with the specified ID, or null if does not have the + * effect. + */ + public function get(Effect $effect) : ?EffectInstance{ + return $this->effects[spl_object_id($effect)] ?? null; + } + + /** + * Returns whether the specified effect is active. + */ + public function has(Effect $effect) : bool{ + return isset($this->effects[spl_object_id($effect)]); + } + + /** + * In the following cases it will return true: + * - if the effect type is not already applied + * - if an existing effect of the same type can be replaced (due to shorter duration or lower level) + */ + public function canAdd(EffectInstance $effect) : bool{ + $index = spl_object_id($effect->getType()); + if(isset($this->effects[$index])){ + $oldEffect = $this->effects[$index]; + if( + abs($effect->getAmplifier()) < $oldEffect->getAmplifier() + || (abs($effect->getAmplifier()) === abs($oldEffect->getAmplifier()) && $effect->getDuration() < $oldEffect->getDuration()) + ){ + return false; + } + } + return true; + } + + /** + * Adds an effect to the collection. + * Existing effects of the same type will be replaced if {@see self::canAdd()} returns true. + * + * @return bool whether the effect has been successfully applied. + */ + public function add(EffectInstance $effect) : bool{ + if($this->canAdd($effect)){ + $index = spl_object_id($effect->getType()); + $replacesOldEffect = isset($this->effects[$index]); + + $this->effects[$index] = $effect; + foreach($this->effectAddHooks as $hook){ + $hook($effect, $replacesOldEffect); + } + + $this->recalculateEffectColor(); + return true; + } + + return false; + } + + /** + * Sets the filter that determines which effects will be displayed in the bubbles. + * + * @phpstan-param \Closure(EffectInstance) : bool $filter + */ + public function setEffectFilterForBubbles(\Closure $filter) : void{ + Utils::validateCallableSignature(fn(EffectInstance $e) : bool => false, $filter); + $this->effectFilterForBubbles = $filter; + } + + /** + * Recalculates the potion bubbles colour based on the active effects. + */ + protected function recalculateEffectColor() : void{ + /** @var Color[] $colors */ + $colors = []; + $ambient = true; + foreach($this->effects as $effect){ + if(($this->effectFilterForBubbles)($effect)){ + $level = $effect->getEffectLevel(); + $color = $effect->getColor(); + for($i = 0; $i < $level; ++$i){ + $colors[] = $color; + } + + if(!$effect->isAmbient()){ + $ambient = false; + } + } + } + + if(count($colors) > 0){ + $this->bubbleColor = Color::mix(...$colors); + $this->onlyAmbientEffects = $ambient; + }else{ + $this->bubbleColor = new Color(0, 0, 0, 0); + $this->onlyAmbientEffects = false; + } + } + + public function getBubbleColor() : Color{ + return $this->bubbleColor; + } + + public function hasOnlyAmbientEffects() : bool{ + return $this->onlyAmbientEffects; + } + + /** + * @return \Closure[]|ObjectSet + * @phpstan-return ObjectSet<\Closure(EffectInstance, bool $replacesOldEffect) : void> + */ + public function getEffectAddHooks() : ObjectSet{ + return $this->effectAddHooks; + } + + /** + * @return \Closure[]|ObjectSet + * @phpstan-return ObjectSet<\Closure(EffectInstance) : void> + */ + public function getEffectRemoveHooks() : ObjectSet{ + return $this->effectRemoveHooks; + } +} diff --git a/src/entity/effect/EffectManager.php b/src/entity/effect/EffectManager.php index 747662dc7..448681ede 100644 --- a/src/entity/effect/EffectManager.php +++ b/src/entity/effect/EffectManager.php @@ -23,56 +23,18 @@ declare(strict_types=1); namespace pocketmine\entity\effect; -use pocketmine\color\Color; use pocketmine\entity\Living; use pocketmine\event\entity\EntityEffectAddEvent; use pocketmine\event\entity\EntityEffectRemoveEvent; -use pocketmine\utils\ObjectSet; -use function abs; use function count; use function spl_object_id; -class EffectManager{ - /** @var EffectInstance[] */ - protected array $effects = []; - - protected Color $bubbleColor; - protected bool $onlyAmbientEffects = false; - - /** - * @var \Closure[]|ObjectSet - * @phpstan-var ObjectSet<\Closure(EffectInstance, bool $replacesOldEffect) : void> - */ - protected ObjectSet $effectAddHooks; - /** - * @var \Closure[]|ObjectSet - * @phpstan-var ObjectSet<\Closure(EffectInstance) : void> - */ - protected ObjectSet $effectRemoveHooks; +class EffectManager extends EffectCollection{ public function __construct( private Living $entity ){ - $this->bubbleColor = new Color(0, 0, 0, 0); - $this->effectAddHooks = new ObjectSet(); - $this->effectRemoveHooks = new ObjectSet(); - } - - /** - * Returns an array of Effects currently active on the mob. - * @return EffectInstance[] - */ - public function all() : array{ - return $this->effects; - } - - /** - * Removes all effects from the mob. - */ - public function clear() : void{ - foreach($this->effects as $effect){ - $this->remove($effect->getType()); - } + parent::__construct(); } /** @@ -91,55 +53,17 @@ class EffectManager{ return; } - unset($this->effects[$index]); $effect->getType()->remove($this->entity, $effect); - foreach($this->effectRemoveHooks as $hook){ - $hook($effect); - } - - $this->recalculateEffectColor(); + parent::remove($effectType); } } - /** - * Returns the effect instance active on this entity with the specified ID, or null if the mob does not have the - * effect. - */ - public function get(Effect $effect) : ?EffectInstance{ - return $this->effects[spl_object_id($effect)] ?? null; - } - - /** - * Returns whether the specified effect is active on the mob. - */ - public function has(Effect $effect) : bool{ - return isset($this->effects[spl_object_id($effect)]); - } - - /** - * Adds an effect to the mob. - * If a weaker effect of the same type is already applied, it will be replaced. - * If a weaker or equal-strength effect is already applied but has a shorter duration, it will be replaced. - * - * @return bool whether the effect has been successfully applied. - */ public function add(EffectInstance $effect) : bool{ - $oldEffect = null; - $cancelled = false; - $index = spl_object_id($effect->getType()); - if(isset($this->effects[$index])){ - $oldEffect = $this->effects[$index]; - if( - abs($effect->getAmplifier()) < $oldEffect->getAmplifier() - || (abs($effect->getAmplifier()) === abs($oldEffect->getAmplifier()) && $effect->getDuration() < $oldEffect->getDuration()) - ){ - $cancelled = true; - } - } + $oldEffect = $this->effects[$index] ?? null; $ev = new EntityEffectAddEvent($this->entity, $effect, $oldEffect); - if($cancelled){ + if(!$this->canAdd($effect)){ $ev->cancel(); } @@ -153,53 +77,8 @@ class EffectManager{ } $effect->getType()->add($this->entity, $effect); - foreach($this->effectAddHooks as $hook){ - $hook($effect, $oldEffect !== null); - } - $this->effects[$index] = $effect; - - $this->recalculateEffectColor(); - - return true; - } - - /** - * Recalculates the mob's potion bubbles colour based on the active effects. - */ - protected function recalculateEffectColor() : void{ - /** @var Color[] $colors */ - $colors = []; - $ambient = true; - foreach($this->effects as $effect){ - if($effect->isVisible() && $effect->getType()->hasBubbles()){ - $level = $effect->getEffectLevel(); - $color = $effect->getColor(); - for($i = 0; $i < $level; ++$i){ - $colors[] = $color; - } - - if(!$effect->isAmbient()){ - $ambient = false; - } - } - } - - if(count($colors) > 0){ - $this->bubbleColor = Color::mix(...$colors); - $this->onlyAmbientEffects = $ambient; - }else{ - $this->bubbleColor = new Color(0, 0, 0, 0); - $this->onlyAmbientEffects = false; - } - } - - public function getBubbleColor() : Color{ - return $this->bubbleColor; - } - - public function hasOnlyAmbientEffects() : bool{ - return $this->onlyAmbientEffects; + return parent::add($effect); } public function tick(int $tickDiff = 1) : bool{ @@ -216,20 +95,4 @@ class EffectManager{ return count($this->effects) > 0; } - - /** - * @return \Closure[]|ObjectSet - * @phpstan-return ObjectSet<\Closure(EffectInstance, bool $replacesOldEffect) : void> - */ - public function getEffectAddHooks() : ObjectSet{ - return $this->effectAddHooks; - } - - /** - * @return \Closure[]|ObjectSet - * @phpstan-return ObjectSet<\Closure(EffectInstance) : void> - */ - public function getEffectRemoveHooks() : ObjectSet{ - return $this->effectRemoveHooks; - } } diff --git a/src/entity/object/AreaEffectCloud.php b/src/entity/object/AreaEffectCloud.php new file mode 100644 index 000000000..3f7d1fff9 --- /dev/null +++ b/src/entity/object/AreaEffectCloud.php @@ -0,0 +1,427 @@ + entity ID => expiration */ + protected array $victims = []; + + protected int $maxAge = self::DEFAULT_DURATION; + protected int $maxAgeChangeOnUse = self::DEFAULT_DURATION_CHANGE_ON_USE; + + protected int $reapplicationDelay = self::REAPPLICATION_DELAY; + + protected int $pickupCount = 0; + protected float $radiusChangeOnPickup = self::DEFAULT_RADIUS_CHANGE_ON_PICKUP; + + protected float $initialRadius = self::DEFAULT_RADIUS; + protected float $radius = self::DEFAULT_RADIUS; + protected float $radiusChangeOnUse = self::DEFAULT_RADIUS_CHANGE_ON_USE; + protected float $radiusChangePerTick = self::DEFAULT_RADIUS_CHANGE_PER_TICK; + + public function __construct( + Location $location, + ?CompoundTag $nbt = null + ){ + parent::__construct($location, $nbt); + } + + protected function getInitialSizeInfo() : EntitySizeInfo{ return new EntitySizeInfo(0.5, $this->radius * 2); } + + protected function getInitialDragMultiplier() : float{ return 0.0; } + + protected function getInitialGravity() : float{ return 0.0; } + + protected function initEntity(CompoundTag $nbt) : void{ + parent::initEntity($nbt); + + $this->effectCollection = new EffectCollection(); + $this->effectCollection->getEffectAddHooks()->add(function() : void{ $this->networkPropertiesDirty = true; }); + $this->effectCollection->getEffectRemoveHooks()->add(function() : void{ $this->networkPropertiesDirty = true; }); + $this->effectCollection->setEffectFilterForBubbles(static fn(EffectInstance $e) : bool => $e->isVisible()); + + $worldTime = $this->getWorld()->getTime(); + $this->age = max($worldTime - $nbt->getLong(self::TAG_SPAWN_TICK, $worldTime), 0); + $this->maxAge = $nbt->getInt(self::TAG_DURATION, self::DEFAULT_DURATION); + $this->maxAgeChangeOnUse = $nbt->getInt(self::TAG_DURATION_ON_USE, self::DEFAULT_DURATION_CHANGE_ON_USE); + $this->pickupCount = $nbt->getInt(self::TAG_PICKUP_COUNT, 0); + $this->reapplicationDelay = $nbt->getInt(self::TAG_REAPPLICATION_DELAY, self::REAPPLICATION_DELAY); + + $this->initialRadius = $nbt->getFloat(self::TAG_INITIAL_RADIUS, self::DEFAULT_RADIUS); + $this->setRadius($nbt->getFloat(self::TAG_RADIUS, $this->initialRadius)); + $this->radiusChangeOnPickup = $nbt->getFloat(self::TAG_RADIUS_CHANGE_ON_PICKUP, self::DEFAULT_RADIUS_CHANGE_ON_PICKUP); + $this->radiusChangeOnUse = $nbt->getFloat(self::TAG_RADIUS_ON_USE, self::DEFAULT_RADIUS_CHANGE_ON_USE); + $this->radiusChangePerTick = $nbt->getFloat(self::TAG_RADIUS_PER_TICK, self::DEFAULT_RADIUS_CHANGE_PER_TICK); + + $effectsTag = $nbt->getListTag(self::TAG_EFFECTS, CompoundTag::class); + if($effectsTag !== null){ + foreach($effectsTag as $e){ + $effect = EffectIdMap::getInstance()->fromId($e->getByte("Id")); + if($effect === null){ + continue; + } + + $this->effectCollection->add(new EffectInstance( + $effect, + $e->getInt("Duration"), + Binary::unsignByte($e->getByte("Amplifier")), + $e->getByte("ShowParticles", 1) !== 0, + $e->getByte("Ambient", 0) !== 0 + )); + } + } + } + + public function saveNBT() : CompoundTag{ + $nbt = parent::saveNBT(); + + $nbt->setLong(self::TAG_SPAWN_TICK, $this->getWorld()->getTime() - $this->age); + $nbt->setShort(self::TAG_POTION_ID, PotionTypeIds::WATER); //not used, mobEffects is used exclusively in Bedrock + $nbt->setInt(self::TAG_DURATION, $this->maxAge); + $nbt->setInt(self::TAG_DURATION_ON_USE, $this->maxAgeChangeOnUse); + $nbt->setInt(self::TAG_PICKUP_COUNT, $this->pickupCount); + $nbt->setInt(self::TAG_REAPPLICATION_DELAY, $this->reapplicationDelay); + $nbt->setFloat(self::TAG_INITIAL_RADIUS, $this->initialRadius); + $nbt->setFloat(self::TAG_RADIUS, $this->radius); + $nbt->setFloat(self::TAG_RADIUS_CHANGE_ON_PICKUP, $this->radiusChangeOnPickup); + $nbt->setFloat(self::TAG_RADIUS_ON_USE, $this->radiusChangeOnUse); + $nbt->setFloat(self::TAG_RADIUS_PER_TICK, $this->radiusChangePerTick); + + if(count($this->effectCollection->all()) > 0){ + $effects = []; + foreach($this->effectCollection->all() as $effect){ + $effects[] = CompoundTag::create() + ->setByte("Id", EffectIdMap::getInstance()->toId($effect->getType())) + ->setByte("Amplifier", Binary::signByte($effect->getAmplifier())) + ->setInt("Duration", $effect->getDuration()) + ->setByte("Ambient", $effect->isAmbient() ? 1 : 0) + ->setByte("ShowParticles", $effect->isVisible() ? 1 : 0); + } + $nbt->setTag(self::TAG_EFFECTS, new ListTag($effects)); + } + + return $nbt; + } + + public function isFireProof() : bool{ + return true; + } + + public function canBeCollidedWith() : bool{ + return false; + } + + /** + * Returns the current age of the cloud (in ticks). + */ + public function getAge() : int{ + return $this->age; + } + + public function getEffects() : EffectCollection{ + return $this->effectCollection; + } + + /** + * Returns the initial radius (in blocks). + */ + public function getInitialRadius() : float{ + return $this->initialRadius; + } + + /** + * Returns the current radius (in blocks). + */ + public function getRadius() : float{ + return $this->radius; + } + + /** + * Sets the current radius (in blocks). + */ + protected function setRadius(float $radius) : void{ + $this->radius = $radius; + $this->setSize($this->getInitialSizeInfo()); + $this->networkPropertiesDirty = true; + } + + /** + * Returns the amount that the radius of this cloud will add by when it is + * picked up (in blocks). Usually negative resulting in a radius reduction. + * + * Applied when getting dragon breath bottle. + */ + public function getRadiusChangeOnPickup() : float{ + return $this->radiusChangeOnPickup; + } + + /** + * Sets the amount that the radius of this cloud will add by when it is + * picked up (in blocks). Usually negative resulting in a radius reduction. + * + * Applied when getting dragon breath bottle. + */ + public function setRadiusChangeOnPickup(float $radiusChangeOnPickup) : void{ + $this->radiusChangeOnPickup = $radiusChangeOnPickup; + } + + /** + * Returns the amount that the radius of this cloud will add by when it + * applies an effect to an entity (in blocks). Usually negative resulting in a radius reduction. + */ + public function getRadiusChangeOnUse() : float{ + return $this->radiusChangeOnUse; + } + + /** + * Sets the amount that the radius of this cloud will add by when it + * applies an effect to an entity (in blocks). + */ + public function setRadiusChangeOnUse(float $radiusChangeOnUse) : void{ + $this->radiusChangeOnUse = $radiusChangeOnUse; + } + + /** + * Returns the amount that the radius of this cloud will add by when an update + * is performed (in blocks). Usually negative resulting in a radius reduction. + */ + public function getRadiusChangePerTick() : float{ + return $this->radiusChangePerTick; + } + + /** + * Sets the amount that the radius of this cloud will add by when an update is performed (in blocks). + */ + public function setRadiusChangePerTick(float $radiusChangePerTick) : void{ + $this->radiusChangePerTick = $radiusChangePerTick; + } + + /** + * Returns the age at which the cloud will despawn. + */ + public function getMaxAge() : int{ + return $this->maxAge; + } + + /** + * Sets the age at which the cloud will despawn. + */ + public function setMaxAge(int $maxAge) : void{ + $this->maxAge = $maxAge; + } + + /** + * Returns the amount that the max age of this cloud will change by when it + * applies an effect to an entity (in ticks). + */ + public function getMaxAgeChangeOnUse() : int{ + return $this->maxAgeChangeOnUse; + } + + /** + * Sets the amount that the max age of this cloud will change by when it + * applies an effect to an entity (in ticks). + */ + public function setMaxAgeChangeOnUse(int $maxAgeChangeOnUse) : void{ + $this->maxAgeChangeOnUse = $maxAgeChangeOnUse; + } + + /** + * Returns the time that an entity will be immune from subsequent exposure (in ticks). + */ + public function getReapplicationDelay() : int{ + return $this->reapplicationDelay; + } + + /** + * Sets the time that an entity will be immune from subsequent exposure (in ticks). + */ + public function setReapplicationDelay(int $delay) : void{ + $this->reapplicationDelay = $delay; + } + + protected function entityBaseTick(int $tickDiff = 1) : bool{ + $hasUpdate = parent::entityBaseTick($tickDiff); + + $this->age += $tickDiff; + $radius = $this->radius + ($this->radiusChangePerTick * $tickDiff); + if($radius < 0.5){ + $this->flagForDespawn(); + return true; + } + $this->setRadius($radius); + if($this->age >= self::UPDATE_DELAY && ($this->age % self::UPDATE_DELAY) === 0){ + if($this->age > $this->maxAge){ + $this->flagForDespawn(); + return true; + } + + foreach($this->victims as $entityId => $expiration){ + if($this->age >= $expiration){ + unset($this->victims[$entityId]); + } + } + + $entities = []; + $radiusChange = 0.0; + $maxAgeChange = 0; + foreach($this->getWorld()->getCollidingEntities($this->getBoundingBox(), $this) as $entity){ + if(!$entity instanceof Living || isset($this->victims[$entity->getId()])){ + continue; + } + $entityPosition = $entity->getPosition(); + $xDiff = $entityPosition->getX() - $this->location->getX(); + $zDiff = $entityPosition->getZ() - $this->location->getZ(); + if(($xDiff ** 2 + $zDiff ** 2) > $this->radius ** 2){ + continue; + } + $entities[] = $entity; + if($this->radiusChangeOnUse !== 0.0){ + $radiusChange += $this->radiusChangeOnUse; + if($this->radius + $radiusChange <= 0){ + break; + } + } + if($this->maxAgeChangeOnUse !== 0){ + $maxAgeChange += $this->maxAgeChangeOnUse; + if($this->maxAge + $maxAgeChange <= 0){ + break; + } + } + } + if(count($entities) === 0){ + return $hasUpdate; + } + + $ev = new AreaEffectCloudApplyEvent($this, $entities); + $ev->call(); + if($ev->isCancelled()){ + return $hasUpdate; + } + + foreach($ev->getAffectedEntities() as $entity){ + foreach($this->effectCollection->all() as $effect){ + $effect = clone $effect; //avoid accidental modification + if($effect->getType() instanceof InstantEffect){ + $effect->getType()->applyEffect($entity, $effect, 0.5, $this); + }else{ + $entity->getEffects()->add($effect->setDuration((int) round($effect->getDuration() / 4))); + } + } + if($this->reapplicationDelay !== 0){ + $this->victims[$entity->getId()] = $this->age + $this->reapplicationDelay; + } + } + + $radius = $this->radius + $radiusChange; + $maxAge = $this->maxAge + $maxAgeChange; + if($radius <= 0 || $maxAge <= 0){ + $this->flagForDespawn(); + return true; + } + $this->setRadius($radius); + $this->setMaxAge($maxAge); + $hasUpdate = true; + } + + return $hasUpdate; + } + + protected function syncNetworkData(EntityMetadataCollection $properties) : void{ + parent::syncNetworkData($properties); + + //visual properties + $properties->setFloat(EntityMetadataProperties::AREA_EFFECT_CLOUD_RADIUS, $this->radius); + $properties->setInt(EntityMetadataProperties::POTION_COLOR, Binary::signInt(( + count($this->effectCollection->all()) === 0 ? PotionSplashParticle::DEFAULT_COLOR() : $this->effectCollection->getBubbleColor() + )->toARGB())); + + //these are properties the client expects, and are used for client-sided logic, which we don't want + $properties->setByte(EntityMetadataProperties::POTION_AMBIENT, 0); + $properties->setInt(EntityMetadataProperties::AREA_EFFECT_CLOUD_DURATION, -1); + $properties->setFloat(EntityMetadataProperties::AREA_EFFECT_CLOUD_RADIUS_CHANGE_ON_PICKUP, 0); + $properties->setFloat(EntityMetadataProperties::AREA_EFFECT_CLOUD_RADIUS_PER_TICK, 0); + $properties->setInt(EntityMetadataProperties::AREA_EFFECT_CLOUD_SPAWN_TIME, 0); + $properties->setFloat(EntityMetadataProperties::AREA_EFFECT_CLOUD_PICKUP_COUNT, 0); + $properties->setInt(EntityMetadataProperties::AREA_EFFECT_CLOUD_WAITING, 0); + } + + protected function destroyCycles() : void{ + //wipe out callback refs + $this->effectCollection = new EffectCollection(); + parent::destroyCycles(); + } +} diff --git a/src/entity/object/EndCrystal.php b/src/entity/object/EndCrystal.php index 5ea74bfac..9658c6d0c 100644 --- a/src/entity/object/EndCrystal.php +++ b/src/entity/object/EndCrystal.php @@ -52,6 +52,8 @@ class EndCrystal extends Entity implements Explosive{ protected bool $showBase = false; protected ?Vector3 $beamTarget = null; + private bool $primed = false; + protected function getInitialSizeInfo() : EntitySizeInfo{ return new EntitySizeInfo(2.0, 2.0); } protected function getInitialDragMultiplier() : float{ return 1.0; } @@ -88,11 +90,9 @@ class EndCrystal extends Entity implements Explosive{ parent::attack($source); if( $source->getCause() !== EntityDamageEvent::CAUSE_VOID && - !$this->isFlaggedForDespawn() && !$source->isCancelled() ){ - $this->flagForDespawn(); - $this->explode(); + $this->primed = true; } } @@ -125,6 +125,13 @@ class EndCrystal extends Entity implements Explosive{ return $nbt; } + protected function onDeathUpdate(int $tickDiff) : bool{ + if($this->primed){ + $this->explode(); + } + return true; + } + public function explode() : void{ $ev = new EntityPreExplodeEvent($this, 6); $ev->call(); diff --git a/src/entity/projectile/SplashPotion.php b/src/entity/projectile/SplashPotion.php index dd4f3d3b4..0128fbc1a 100644 --- a/src/entity/projectile/SplashPotion.php +++ b/src/entity/projectile/SplashPotion.php @@ -32,10 +32,10 @@ use pocketmine\entity\effect\InstantEffect; use pocketmine\entity\Entity; use pocketmine\entity\Living; use pocketmine\entity\Location; +use pocketmine\entity\object\AreaEffectCloud; use pocketmine\event\entity\ProjectileHitBlockEvent; use pocketmine\event\entity\ProjectileHitEntityEvent; use pocketmine\event\entity\ProjectileHitEvent; -use pocketmine\item\Potion; use pocketmine\item\PotionType; use pocketmine\nbt\tag\CompoundTag; use pocketmine\network\mcpe\protocol\types\entity\EntityIds; @@ -96,8 +96,8 @@ class SplashPotion extends Throwable{ $this->getWorld()->addParticle($this->location, $particle); $this->broadcastSound(new PotionSplashSound()); - if($hasEffects){ - if(!$this->willLinger()){ + if(!$this->willLinger()){ + if($hasEffects){ foreach($this->getWorld()->getCollidingEntities($this->boundingBox->expandedCopy(4.125, 2.125, 4.125), $this) as $entity){ if($entity instanceof Living){ $distanceSquared = $entity->getEyePos()->distanceSquared($this->location); @@ -126,10 +126,18 @@ class SplashPotion extends Throwable{ } } } - }else{ - //TODO: lingering potions } - }elseif($event instanceof ProjectileHitBlockEvent && $this->getPotionType() === PotionType::WATER){ + }else{ + $entity = new AreaEffectCloud(Location::fromObject($this->location->floor()->add(0.5, 0.5, 0.5), $this->getWorld())); + foreach($this->potionType->getEffects() as $effect){ + $entity->getEffects()->add($effect); + } + if(($owner = $this->getOwningEntity()) !== null && !$owner->isClosed()){ + $entity->setOwningEntity($owner); + } + $entity->spawnToAll(); + } + if(!$hasEffects && $event instanceof ProjectileHitBlockEvent && $this->getPotionType() === PotionType::WATER){ $blockIn = $event->getBlockHit()->getSide($event->getRayTraceResult()->getHitFace()); if($blockIn->hasTypeTag(BlockTypeTags::FIRE)){ diff --git a/src/event/entity/AreaEffectCloudApplyEvent.php b/src/event/entity/AreaEffectCloudApplyEvent.php new file mode 100644 index 000000000..c6246a339 --- /dev/null +++ b/src/event/entity/AreaEffectCloudApplyEvent.php @@ -0,0 +1,64 @@ + + */ +class AreaEffectCloudApplyEvent extends EntityEvent implements Cancellable{ + use CancellableTrait; + + /** + * @param Living[] $affectedEntities + */ + public function __construct( + AreaEffectCloud $entity, + protected array $affectedEntities + ){ + $this->entity = $entity; + } + + /** + * @return AreaEffectCloud + */ + public function getEntity(){ + return $this->entity; + } + + /** + * Returns the affected entities. + * + * @return Living[] + */ + public function getAffectedEntities() : array{ + return $this->affectedEntities; + } +} diff --git a/src/item/Item.php b/src/item/Item.php index ab1d175ca..0f0ebddbe 100644 --- a/src/item/Item.php +++ b/src/item/Item.php @@ -779,6 +779,24 @@ class Item implements \JsonSerializable{ } } + /** + * Same as nbtDeserialize(), but purposely suppresses data errors and returns AIR if deserialization fails. + * An error will be logged to the global logger if this happens. + * + * @param string $errorLogContext Used in log messages if deserialization fails to aid debugging (e.g. inventory owner, slot number, etc.) + */ + public static function safeNbtDeserialize(CompoundTag $tag, string $errorLogContext, ?\Logger $logger = null) : Item{ + try{ + return self::nbtDeserialize($tag); + }catch(SavedDataLoadingException $e){ + //TODO: what if the intention was to suppress logging? + $logger ??= \GlobalLogger::get(); + $logger->error("$errorLogContext: Error deserializing item (item will be replaced by AIR): " . $e->getMessage()); + //no trace here, otherwise things could get very noisy + return VanillaItems::AIR(); + } + } + public function __clone(){ $this->nbt = clone $this->nbt; if($this->blockEntityTag !== null){ diff --git a/src/item/SplashPotion.php b/src/item/SplashPotion.php index e1c9167ac..947b1b4be 100644 --- a/src/item/SplashPotion.php +++ b/src/item/SplashPotion.php @@ -33,6 +33,16 @@ class SplashPotion extends ProjectileItem{ private PotionType $potionType = PotionType::WATER; + public function __construct( + ItemIdentifier $identifier, + string $name = "Splash Potion", + array $enchantmentTags = [], + private bool $linger = false + ){ + //TODO: remove unnecessary default parameters in PM6, they remain because backward compatibility + parent::__construct($identifier, $name, $enchantmentTags); + } + protected function describeState(RuntimeDataDescriber $w) : void{ $w->enum($this->potionType); } @@ -52,10 +62,19 @@ class SplashPotion extends ProjectileItem{ } protected function createEntity(Location $location, Player $thrower) : Throwable{ - return new SplashPotionEntity($location, $thrower, $this->potionType); + $projectile = new SplashPotionEntity($location, $thrower, $this->potionType); + $projectile->setLinger($this->linger); + return $projectile; } public function getThrowForce() : float{ return 0.5; } + + /** + * Returns whether this splash potion will create an area-effect cloud on impact. + */ + public function willLinger() : bool{ + return $this->linger; + } } diff --git a/src/item/StringToItemParser.php b/src/item/StringToItemParser.php index 63fa88538..77676ea01 100644 --- a/src/item/StringToItemParser.php +++ b/src/item/StringToItemParser.php @@ -1226,6 +1226,7 @@ final class StringToItemParser extends StringToTParser{ $result->register($prefix("potion"), fn() => Items::POTION()->setType($potionType)); $result->register($prefix("splash_potion"), fn() => Items::SPLASH_POTION()->setType($potionType)); + $result->register($prefix("lingering_potion"), fn() => Items::LINGERING_POTION()->setType($potionType)); } } diff --git a/src/item/VanillaItems.php b/src/item/VanillaItems.php index 31a62d1aa..c9b3f4917 100644 --- a/src/item/VanillaItems.php +++ b/src/item/VanillaItems.php @@ -222,6 +222,7 @@ use function strtolower; * @method static Armor LEATHER_CAP() * @method static Armor LEATHER_PANTS() * @method static Armor LEATHER_TUNIC() + * @method static SplashPotion LINGERING_POTION() * @method static Item MAGMA_CREAM() * @method static Boat MANGROVE_BOAT() * @method static HangingSign MANGROVE_HANGING_SIGN() @@ -543,6 +544,7 @@ final class VanillaItems{ self::register("lapis_lazuli", fn(IID $id) => new Item($id, "Lapis Lazuli")); self::register("lava_bucket", fn(IID $id) => new LiquidBucket($id, "Lava Bucket", Blocks::LAVA())); self::register("leather", fn(IID $id) => new Item($id, "Leather")); + self::register("lingering_potion", fn(IID $id) => new SplashPotion($id, "Lingering Potion", linger: true)); self::register("magma_cream", fn(IID $id) => new Item($id, "Magma Cream")); self::register("mangrove_sign", fn(IID $id) => new ItemBlockWallOrFloor($id, Blocks::MANGROVE_SIGN(), Blocks::MANGROVE_WALL_SIGN())); self::register("mangrove_hanging_sign", fn(IID $id) => new HangingSign($id, "Mangrove Hanging Sign", Blocks::MANGROVE_CEILING_CENTER_HANGING_SIGN(), Blocks::MANGROVE_CEILING_EDGES_HANGING_SIGN(), Blocks::MANGROVE_WALL_HANGING_SIGN()));