From 661ef71ed7a6a06d620a00697e911ac2a14f189b Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 12:57:08 +0000 Subject: [PATCH 01/11] feat(aim-assist): validate AimAssistBuilder input and document it - distance() only accepts 1-16 blocks, viewAngle() requires positive finite angles. - send() rejects presets that are not registered in AimAssistPresetRegistry. - Add an explicit clear() and PHPDoc for every method. --- .../cameraapi/aimassist/AimAssistBuilder.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/kim/present/cameraapi/aimassist/AimAssistBuilder.php b/src/kim/present/cameraapi/aimassist/AimAssistBuilder.php index 7f3e7f4..eec60f5 100644 --- a/src/kim/present/cameraapi/aimassist/AimAssistBuilder.php +++ b/src/kim/present/cameraapi/aimassist/AimAssistBuilder.php @@ -18,6 +18,11 @@ final class AimAssistBuilder{ private CameraAimAssistTargetMode $targetMode; private bool $showDebugRender = false; + /** Smallest distance accepted by the client. */ + public const MIN_DISTANCE = 1.0; + /** Largest distance accepted by the client. */ + public const MAX_DISTANCE = 16.0; + public function __construct( private readonly CameraSession $session ){ @@ -27,37 +32,115 @@ public function __construct( $this->targetMode = CameraAimAssistTargetMode::ANGLE; } + /** + * Sets the aim assist preset to activate. + * + * The preset has to be registered in {@see AimAssistPresetRegistry} (checked in {@see self::send()}). + * Passing null or an empty string makes {@see self::send()} clear the active aim assist instead, like + * {@see self::clear()}. + * + * @param string|null $presetId Preset identifier, e.g. {@see DefaultAimAssistPresetIds::MINECRAFT_DEFAULT}. + * + * @return self + */ public function preset(?string $presetId) : self{ $this->presetId = $presetId; return $this; } + /** + * Makes {@see self::send()} clear the active aim assist for the player. + * + * @return self + */ + public function clear() : self{ + $this->presetId = null; + return $this; + } + + /** + * Sets the size of the area in which targets are considered, in degrees. + * + * @param float $horizontal Horizontal angle, must be finite and greater than 0. + * @param float $vertical Vertical angle, must be finite and greater than 0. + * + * @return self + * + * @throws \InvalidArgumentException If an angle is not a finite number greater than 0. + */ public function viewAngle(float $horizontal, float $vertical) : self{ + foreach(['horizontal' => $horizontal, 'vertical' => $vertical] as $name => $angle){ + if(!is_finite($angle) || $angle <= 0.0){ + throw new \InvalidArgumentException("Aim assist $name view angle must be greater than 0, got $angle"); + } + } $this->viewAngle = new Vector2($horizontal, $vertical); return $this; } + /** + * Sets the maximum distance (in blocks) at which targets are considered. + * + * @param float $distance Distance between {@see self::MIN_DISTANCE} and {@see self::MAX_DISTANCE}. + * + * @return self + * + * @throws \InvalidArgumentException If the distance is outside the range supported by the client. + */ public function distance(float $distance) : self{ + if(!is_finite($distance) || $distance < self::MIN_DISTANCE || $distance > self::MAX_DISTANCE){ + throw new \InvalidArgumentException( + "Aim assist distance must be between " . self::MIN_DISTANCE . " and " . self::MAX_DISTANCE . + ", got $distance" + ); + } $this->distance = $distance; return $this; } + /** + * Sets how targets are selected (by angle or by distance). + * + * @param CameraAimAssistTargetMode $mode + * + * @return self + */ public function targetMode(CameraAimAssistTargetMode $mode) : self{ $this->targetMode = $mode; return $this; } + /** + * Shows the client's debug rendering of the aim assist area. + * + * @param bool $showDebugRender + * + * @return self + */ public function debug(bool $showDebugRender = true) : self{ $this->showDebugRender = $showDebugRender; return $this; } + /** + * Sends the aim assist instruction: activates the configured preset, or clears the active one when no preset + * is set. Does nothing if the player is offline. + * + * @return CameraSession Returns the session for method chaining. + * + * @throws \InvalidArgumentException If the preset is not registered in {@see AimAssistPresetRegistry}. + */ public function send() : CameraSession{ $player = $this->session->getPlayer(); if($player === null || !$player->isConnected()){ return $this->session; } + if($this->presetId !== null && $this->presetId !== '' && + AimAssistPresetRegistry::getPreset($this->presetId) === null){ + throw new \InvalidArgumentException("Unknown aim assist preset: " . $this->presetId); + } + if($this->presetId === null || $this->presetId === ''){ $packet = CameraAimAssistPacket::create( '', From 4c182db889bb7b4cf6baa7cf6243526d6c8c3c10 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 12:59:05 +0000 Subject: [PATCH 02/11] feat(aim-assist): add actor priority builder and client toggle event - session->aimAssistActorPriority() wraps CameraAimAssistActorPriorityPacket and resolves the preset/category/actor indexes from AimAssistPresetRegistry (new getPresetIndex/getCategoryIndex). - Handle the serverbound ClientCameraAimAssistPacket: store the state on the session (isClientAimAssistAllowed()) and call CameraAimAssistToggleEvent. --- src/kim/present/cameraapi/Main.php | 24 +++++ .../AimAssistActorPriorityBuilder.php | 101 ++++++++++++++++++ .../aimassist/AimAssistPresetRegistry.php | 18 ++++ .../event/CameraAimAssistToggleEvent.php | 65 +++++++++++ .../cameraapi/session/CameraSession.php | 23 ++++ 5 files changed, 231 insertions(+) create mode 100644 src/kim/present/cameraapi/aimassist/AimAssistActorPriorityBuilder.php create mode 100644 src/kim/present/cameraapi/event/CameraAimAssistToggleEvent.php diff --git a/src/kim/present/cameraapi/Main.php b/src/kim/present/cameraapi/Main.php index 19a38a2..c95fa3c 100644 --- a/src/kim/present/cameraapi/Main.php +++ b/src/kim/present/cameraapi/Main.php @@ -29,13 +29,16 @@ use kim\present\cameraapi\aimassist\AimAssistPresetRegistry; use kim\present\cameraapi\camera\preset\CameraPresetRegistry; +use kim\present\cameraapi\event\CameraAimAssistToggleEvent; use kim\present\cameraapi\marker\CameraMarkerEntity; use kim\present\cameraapi\session\CameraSessionManager; use pocketmine\entity\Skin; use pocketmine\event\Listener; use pocketmine\event\player\PlayerJoinEvent; use pocketmine\event\player\PlayerQuitEvent; +use pocketmine\event\server\DataPacketReceiveEvent; use pocketmine\event\server\DataPacketSendEvent; +use pocketmine\network\mcpe\protocol\ClientCameraAimAssistPacket; use pocketmine\network\mcpe\protocol\ResourcePackStackPacket; use pocketmine\network\mcpe\protocol\StartGamePacket; use pocketmine\network\mcpe\protocol\types\Experiments; @@ -88,6 +91,27 @@ public function onPlayerQuit(PlayerQuitEvent $event) : void{ CameraSessionManager::removeSession($event->getPlayer()); } + /** @priority MONITOR */ + public function onDataPacketReceive(DataPacketReceiveEvent $event) : void{ + $packet = $event->getPacket(); + if(!$packet instanceof ClientCameraAimAssistPacket){ + return; + } + + $player = $event->getOrigin()->getPlayer(); + if($player === null){ + return; + } + + Camera::of($player)->setClientAimAssistAllowed($packet->getAllowAimAssist()); + (new CameraAimAssistToggleEvent( + $player, + $packet->getPresetId(), + $packet->getActionType(), + $packet->getAllowAimAssist() + ))->call(); + } + /** @priority LOWEST */ public function onDataPacketSend(DataPacketSendEvent $event) : void{ foreach($event->getPackets() as $packet){ diff --git a/src/kim/present/cameraapi/aimassist/AimAssistActorPriorityBuilder.php b/src/kim/present/cameraapi/aimassist/AimAssistActorPriorityBuilder.php new file mode 100644 index 0000000..57c2f54 --- /dev/null +++ b/src/kim/present/cameraapi/aimassist/AimAssistActorPriorityBuilder.php @@ -0,0 +1,101 @@ +aimAssistActorPriority() + * ->add(DefaultAimAssistPresetIds::MINECRAFT_DEFAULT, DefaultAimAssistPresetIds::CATEGORY_DEFAULT, "minecraft:cow", 99) + * ->send(); + * ``` + * + * Note: the packet has not been verified against a real client yet. + */ +final class AimAssistActorPriorityBuilder{ + + /** @var list */ + private array $entries = []; + + public function __construct( + private readonly CameraSession $session + ){} + + /** + * Adds a priority override for an actor of a category. + * + * @param string $presetId Identifier of a registered aim assist preset. + * @param string $categoryId Identifier of a registered aim assist category. + * @param string $actorId Entity identifier listed in the category's entity priorities. + * @param int $priority New priority of the actor. + * + * @return self + * + * @throws \InvalidArgumentException If the preset, the category or the actor is not registered. + */ + public function add(string $presetId, string $categoryId, string $actorId, int $priority) : self{ + $presetIndex = AimAssistPresetRegistry::getPresetIndex($presetId) + ?? throw new \InvalidArgumentException("Unknown aim assist preset: " . $presetId); + $categoryIndex = AimAssistPresetRegistry::getCategoryIndex($categoryId) + ?? throw new \InvalidArgumentException("Unknown aim assist category: " . $categoryId); + + $actorIndex = null; + $category = AimAssistPresetRegistry::getCategory($categoryId); + foreach($category?->getPriorities()->getEntities() ?? [] as $index => $entity){ + if($entity->getIdentifier() === $actorId){ + $actorIndex = $index; + break; + } + } + if($actorIndex === null){ + throw new \InvalidArgumentException("Actor $actorId is not listed in aim assist category $categoryId"); + } + + $this->entries[] = new CameraAimAssistActorPriorityData($presetIndex, $categoryIndex, $actorIndex, $priority); + return $this; + } + + /** + * Sends the collected priority overrides to the player. + * + * @return CameraSession Returns the session for method chaining. + */ + public function send() : CameraSession{ + return $this->session->sendPacket(CameraAimAssistActorPriorityPacket::create($this->entries)); + } +} diff --git a/src/kim/present/cameraapi/aimassist/AimAssistPresetRegistry.php b/src/kim/present/cameraapi/aimassist/AimAssistPresetRegistry.php index 53bbd6a..99755cc 100644 --- a/src/kim/present/cameraapi/aimassist/AimAssistPresetRegistry.php +++ b/src/kim/present/cameraapi/aimassist/AimAssistPresetRegistry.php @@ -241,6 +241,24 @@ public static function getPresets() : array{ return \array_values(self::$presets); } + /** + * Returns the position of the preset in the list sent to clients, or null if it is not registered. + */ + public static function getPresetIndex(string $identifier) : ?int{ + self::checkInit(); + $index = \array_search(\strtolower($identifier), \array_keys(self::$presets), true); + return $index === false ? null : $index; + } + + /** + * Returns the position of the category in the list sent to clients, or null if it is not registered. + */ + public static function getCategoryIndex(string $name) : ?int{ + self::checkInit(); + $index = \array_search(\strtolower($name), \array_keys(self::$categories), true); + return $index === false ? null : $index; + } + public static function toProtocolCategories() : array{ return self::getCategories(); } diff --git a/src/kim/present/cameraapi/event/CameraAimAssistToggleEvent.php b/src/kim/present/cameraapi/event/CameraAimAssistToggleEvent.php new file mode 100644 index 0000000..ed63ad7 --- /dev/null +++ b/src/kim/present/cameraapi/event/CameraAimAssistToggleEvent.php @@ -0,0 +1,65 @@ +player = $player; + } + + /** Identifier of the aim assist preset the client reported about. */ + public function getPresetId() : string{ + return $this->presetId; + } + + /** Whether the client set or cleared the preset. */ + public function getActionType() : CameraAimAssistActionType{ + return $this->actionType; + } + + /** Whether the client allows aim assist (the player's setting). */ + public function isAimAssistAllowed() : bool{ + return $this->allowAimAssist; + } +} diff --git a/src/kim/present/cameraapi/session/CameraSession.php b/src/kim/present/cameraapi/session/CameraSession.php index eb2cc1f..59d50bc 100644 --- a/src/kim/present/cameraapi/session/CameraSession.php +++ b/src/kim/present/cameraapi/session/CameraSession.php @@ -33,6 +33,7 @@ use kim\present\cameraapi\camera\builder\CameraSetBuilder; use kim\present\cameraapi\camera\builder\CameraSplineBuilder; use kim\present\cameraapi\camera\builder\CameraTargetBuilder; +use kim\present\cameraapi\aimassist\AimAssistActorPriorityBuilder; use kim\present\cameraapi\aimassist\AimAssistBuilder; use kim\present\cameraapi\hud\HudPreset; use kim\present\cameraapi\hud\HudPresetRegistry; @@ -62,6 +63,7 @@ final class CameraSession{ private ?CameraTimeline $pausedTimeline = null; /** @var list */ private array $fogStack = []; + private ?bool $clientAimAssistAllowed = null; /** * @param Player $player The player associated with this session. @@ -241,6 +243,27 @@ public function aimAssist() : AimAssistBuilder{ return new AimAssistBuilder($this); } + /** + * Creates a builder that overrides the aim assist priority of individual actors. + */ + public function aimAssistActorPriority() : AimAssistActorPriorityBuilder{ + return new AimAssistActorPriorityBuilder($this); + } + + /** + * Whether the client allows aim assist, as last reported by the client; null if it has not reported yet. + */ + public function isClientAimAssistAllowed() : ?bool{ + return $this->clientAimAssistAllowed; + } + + /** + * @internal Called when the client reports a change of its aim assist setting. + */ + public function setClientAimAssistAllowed(bool $allowed) : void{ + $this->clientAimAssistAllowed = $allowed; + } + /** * Creates a builder for 'Camera Spline' instruction. * Used to create smooth cinematic camera paths. From 4d055f02a8689ad7d4457e9ef7074e5676553355 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 12:59:20 +0000 Subject: [PATCH 03/11] feat(preset): let CameraPresetBuilder inherit the audio listener type The builder forced AUDIO_LISTENER_TYPE_PLAYER, so presets could not inherit the value of their parent (e.g. minecraft:free expects CAMERA). Default to null and accept null in setAudioListenerType(). Behavior change: presets built with CameraPresetBuilder that relied on the implicit PLAYER default now inherit from their parent; call setAudioListenerType(CameraPreset::AUDIO_LISTENER_TYPE_PLAYER) to keep it. --- .../cameraapi/camera/preset/CameraPresetBuilder.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/kim/present/cameraapi/camera/preset/CameraPresetBuilder.php b/src/kim/present/cameraapi/camera/preset/CameraPresetBuilder.php index e0cfbbf..e57f6b4 100644 --- a/src/kim/present/cameraapi/camera/preset/CameraPresetBuilder.php +++ b/src/kim/present/cameraapi/camera/preset/CameraPresetBuilder.php @@ -54,7 +54,7 @@ class CameraPresetBuilder{ private ?Vector3 $entityOffset = null; private ?float $radius = null; private ?float $yawLimitMin = null, $yawLimitMax = null; - private int $audioListenerType = CameraPreset::AUDIO_LISTENER_TYPE_PLAYER; + private ?int $audioListenerType = null; private ?bool $playerEffects = null; private ?CameraPresetAimAssist $aimAssist = null; private ?ControlScheme $controlScheme = null; @@ -295,11 +295,13 @@ public function setYawLimitMax(?float $yawLimitMax) : self{ /** * Sets the audio listener type (e.g., Player or Camera). * - * @param int $audioListenerType See CameraPreset constants. + * Not set by default (null), so the preset inherits the value of its parent preset. + * + * @param int|null $audioListenerType See CameraPreset constants; null to inherit from the parent. * * @return self */ - public function setAudioListenerType(int $audioListenerType) : self{ + public function setAudioListenerType(?int $audioListenerType) : self{ $this->audioListenerType = $audioListenerType; return $this; } From 7a65f95e10a7e097889b3f74e7c7c9c3e6a7a9c3 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 12:59:20 +0000 Subject: [PATCH 04/11] feat(set): expose removeIgnoreStartingValuesComponent Add CameraSetBuilder::removeIgnoreStartingValues(), which maps to the protocol's removeIgnoreStartingValuesComponent field (previously always false). --- .../camera/builder/CameraSetBuilder.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php b/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php index 3e41050..b1d873b 100644 --- a/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php +++ b/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php @@ -66,6 +66,7 @@ final class CameraSetBuilder{ private ?Vector2 $viewOffset = null; private ?Vector3 $entityOffset = null; private ?bool $default = null; + private bool $removeIgnoreStartingValues = false; public function __construct( private readonly CameraSession $session @@ -342,6 +343,20 @@ private function calculateLocalPosition(Vector3 $basePos, float $yaw, Vector3 $o ); } + /** + * Makes the client drop the "ignore starting values" component of the preset (protocol field + * `removeIgnoreStartingValuesComponent`), so values the preset leaves unset are taken from the instruction + * instead of the camera's starting values. + * + * @param bool $remove + * + * @return self + */ + public function removeIgnoreStartingValues(bool $remove = true) : self{ + $this->removeIgnoreStartingValues = $remove; + return $this; + } + /** * Builds the CameraSetInstruction object. * @@ -366,7 +381,7 @@ public function build() : CameraSetInstruction{ $this->viewOffset, $this->entityOffset, $this->default, - false + $this->removeIgnoreStartingValues ); } From 5f924cb908d8cbb7a2ccc1478bb3feb1b8335dd4 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:00:19 +0000 Subject: [PATCH 05/11] feat(session): track client camera state and add reapply() CameraSession now remembers the camera set, FOV and target instructions, the attached entity, the HUD preset and the control scheme that were sent through it (cleared again by clear()/detach/remove target), exposes them through getCurrent*() getters, and can send them (plus the fog stack) again with reapply(), e.g. after a respawn or dimension change. Eases are dropped on reapply so nothing animates twice. --- src/kim/present/cameraapi/hud/HudPreset.php | 4 + .../cameraapi/session/CameraSession.php | 180 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/kim/present/cameraapi/hud/HudPreset.php b/src/kim/present/cameraapi/hud/HudPreset.php index b5a0a00..3a7f14f 100644 --- a/src/kim/present/cameraapi/hud/HudPreset.php +++ b/src/kim/present/cameraapi/hud/HudPreset.php @@ -28,6 +28,7 @@ namespace kim\present\cameraapi\hud; use kim\present\cameraapi\session\CameraSession; +use kim\present\cameraapi\session\CameraSessionManager; use pocketmine\network\mcpe\protocol\SetHudPacket; use pocketmine\network\mcpe\protocol\types\hud\HudElement; use pocketmine\network\mcpe\protocol\types\hud\HudVisibility; @@ -129,6 +130,9 @@ public function send(CameraSession|Player $target) : void{ if($visibleElements !== []){ $session->sendDataPacket(SetHudPacket::create($visibleElements, HudVisibility::RESET)); } + + ($target instanceof CameraSession ? $target : CameraSessionManager::getSession($player)) + ?->setCurrentHud($this); } /** diff --git a/src/kim/present/cameraapi/session/CameraSession.php b/src/kim/present/cameraapi/session/CameraSession.php index 59d50bc..44edc8b 100644 --- a/src/kim/present/cameraapi/session/CameraSession.php +++ b/src/kim/present/cameraapi/session/CameraSession.php @@ -41,8 +41,12 @@ use pocketmine\entity\Entity; use pocketmine\network\mcpe\protocol\CameraInstructionPacket; use pocketmine\network\mcpe\protocol\CameraShakePacket; +use pocketmine\network\mcpe\protocol\ClientboundControlSchemeSetPacket; use pocketmine\network\mcpe\protocol\ClientboundPacket; use pocketmine\network\mcpe\protocol\PlayerFogPacket; +use pocketmine\network\mcpe\protocol\types\camera\CameraFovInstruction; +use pocketmine\network\mcpe\protocol\types\camera\CameraSetInstruction; +use pocketmine\network\mcpe\protocol\types\camera\CameraTargetInstruction; use pocketmine\player\Player; use pocketmine\scheduler\TaskHandler; @@ -65,6 +69,14 @@ final class CameraSession{ private array $fogStack = []; private ?bool $clientAimAssistAllowed = null; + // Client state as last sent through this session (see reapply()) + private ?CameraSetInstruction $currentSet = null; + private ?CameraFovInstruction $currentFov = null; + private ?CameraTargetInstruction $currentTarget = null; + private ?int $attachedEntityId = null; + private ?HudPreset $currentHud = null; + private ?ClientboundControlSchemeSetPacket $currentControlScheme = null; + /** * @param Player $player The player associated with this session. */ @@ -367,6 +379,174 @@ public function sendPacket(ClientboundPacket $pk) : self{ $player = $this->getPlayer(); if($player !== null && $player->isConnected()){ $player->getNetworkSession()->sendDataPacket($pk); + $this->track($pk); + } + return $this; + } + + /** + * Remembers the client state a packet sent through this session changes. + */ + private function track(ClientboundPacket $pk) : void{ + if($pk instanceof ClientboundControlSchemeSetPacket){ + $this->currentControlScheme = $pk; + return; + } + if(!$pk instanceof CameraInstructionPacket){ + return; + } + + if($pk->getClear() === true){ + $this->currentSet = null; + $this->currentFov = null; + $this->currentTarget = null; + } + if($pk->getSet() !== null){ + $this->currentSet = $pk->getSet(); + } + $fov = $pk->getFieldOfView(); + if($fov !== null){ + $this->currentFov = $fov->getClear() ? null : $fov; + } + if($pk->getTarget() !== null){ + $this->currentTarget = $pk->getTarget(); + } + if($pk->getRemoveTarget() === true){ + $this->currentTarget = null; + } + if($pk->getAttachToEntity() !== null){ + $this->attachedEntityId = $pk->getAttachToEntity(); + } + if($pk->getDetachFromEntity() === true){ + $this->attachedEntityId = null; + } + } + + /** + * Returns the camera set instruction that is currently active on the client, or null if there is none + * (nothing was set yet, or the camera was cleared). + */ + public function getCurrentSet() : ?CameraSetInstruction{ + return $this->currentSet; + } + + /** + * Returns the FOV instruction that is currently active on the client, or null if there is none. + */ + public function getCurrentFov() : ?CameraFovInstruction{ + return $this->currentFov; + } + + /** + * Returns the target instruction that is currently active on the client, or null if there is none. + */ + public function getCurrentTarget() : ?CameraTargetInstruction{ + return $this->currentTarget; + } + + /** + * Returns the runtime ID of the entity the camera is attached to, or null if it is not attached. + */ + public function getAttachedEntityId() : ?int{ + return $this->attachedEntityId; + } + + /** + * Returns the HUD preset last applied through the plugin, or null if the HUD was never changed. + */ + public function getCurrentHud() : ?HudPreset{ + return $this->currentHud; + } + + /** + * Returns the control scheme packet last sent through this session, or null if none was sent. + */ + public function getCurrentControlScheme() : ?ClientboundControlSchemeSetPacket{ + return $this->currentControlScheme; + } + + /** + * @internal Called by {@see HudPreset::send()}. + */ + public function setCurrentHud(HudPreset $hud) : void{ + $this->currentHud = $hud; + } + + /** + * Sends the remembered camera, FOV, target, attachment, HUD, control scheme and fog state to the client + * again. + * + * Use it when the client may have lost that state, for example after a respawn or dimension change. Eases are + * dropped so the restored state is applied immediately instead of animating again. + * + * @return self + */ + public function reapply() : self{ + if($this->currentSet !== null){ + $set = $this->currentSet; + $this->sendPacket(CameraInstructionPacket::create( + set: new CameraSetInstruction( + $set->getPreset(), + null, + $set->getCameraPosition(), + $set->getRotation(), + $set->getFacingPosition(), + $set->getViewOffset(), + $set->getEntityOffset(), + $set->getDefault(), + false + ), + clear: null, + fade: null, + target: null, + removeTarget: null, + fieldOfView: null, + spline: null, + attachToEntity: null, + detachFromEntity: null + )); + } + if($this->currentFov !== null){ + $fov = $this->currentFov; + $this->sendPacket(CameraInstructionPacket::create( + set: null, + clear: null, + fade: null, + target: null, + removeTarget: null, + fieldOfView: new CameraFovInstruction($fov->getFieldOfView(), 0.0, $fov->getEaseType(), false), + spline: null, + attachToEntity: null, + detachFromEntity: null + )); + } + if($this->currentTarget !== null){ + $this->sendPacket(CameraInstructionPacket::create( + set: null, + clear: null, + fade: null, + target: $this->currentTarget, + removeTarget: null, + fieldOfView: null, + spline: null, + attachToEntity: null, + detachFromEntity: null + )); + } + if($this->attachedEntityId !== null){ + $this->attachToEntity($this->attachedEntityId); + } + if($this->currentHud !== null){ + $this->currentHud->send($this); + } + if($this->currentControlScheme !== null){ + $this->sendPacket($this->currentControlScheme); + } + if($this->fogStack !== []){ + $this->sendPacket(PlayerFogPacket::create(array_map( + static fn(array $entry) : string => $entry['fogId'], + $this->fogStack + ))); } return $this; } From d6eee5ba827895267faaf5df155288e384b00363 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:00:26 +0000 Subject: [PATCH 06/11] feat(timeline): add isTimelinePlaying() and getWaitingSignal() to CameraSession Lets callers check whether a timeline still has scheduled steps or is paused on waitUntil(), and which signal it is waiting for, before starting another one (play() cancels the running timeline). --- .../cameraapi/session/CameraSession.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/kim/present/cameraapi/session/CameraSession.php b/src/kim/present/cameraapi/session/CameraSession.php index 44edc8b..de8613a 100644 --- a/src/kim/present/cameraapi/session/CameraSession.php +++ b/src/kim/present/cameraapi/session/CameraSession.php @@ -569,6 +569,29 @@ public function stop() : self{ return $this; } + /** + * Whether a timeline is currently running for this session, i.e. it still has scheduled steps or is paused on + * {@see CameraTimeline::waitUntil()}. + */ + public function isTimelinePlaying() : bool{ + if($this->waitingSignal !== null){ + return true; + } + foreach($this->activeTasks as $task){ + if(!$task->isCancelled()){ + return true; + } + } + return false; + } + + /** + * Returns the name of the signal the current timeline is waiting for, or null if it is not waiting. + */ + public function getWaitingSignal() : ?string{ + return $this->waitingSignal; + } + /** * Registers a timeline task to be managed by this session (cancelled when {@see self::stop()} is called). * From 61c383e9c5c1ce922bb64fdce2cfa44e3015016a Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:01:05 +0000 Subject: [PATCH 07/11] feat(timeline): add hud/aimAssist steps and more set options to the JSON parser - New 'hud' (registered preset name) and 'aimAssist' (preset, viewAngle, distance, targetMode, debug, clear) steps. - 'set' steps accept viewOffset, entityOffset and default. - Unknown step types are still skipped, but now logged as a warning so typos are noticed. --- .../timeline/CameraTimelineParser.php | 112 +++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/kim/present/cameraapi/timeline/CameraTimelineParser.php b/src/kim/present/cameraapi/timeline/CameraTimelineParser.php index 388ee59..69e5541 100644 --- a/src/kim/present/cameraapi/timeline/CameraTimelineParser.php +++ b/src/kim/present/cameraapi/timeline/CameraTimelineParser.php @@ -32,11 +32,15 @@ use kim\present\cameraapi\camera\builder\CameraFovBuilder; use kim\present\cameraapi\camera\builder\CameraSetBuilder; use kim\present\cameraapi\camera\builder\CameraTargetBuilder; +use kim\present\cameraapi\hud\HudPresetRegistry; +use kim\present\cameraapi\Main; use kim\present\cameraapi\session\CameraSession; use kim\present\cameraapi\utils\ControlSchemePackets; use pocketmine\color\Color; +use pocketmine\math\Vector2; use pocketmine\math\Vector3; use pocketmine\network\mcpe\protocol\CameraShakePacket; +use pocketmine\network\mcpe\protocol\types\camera\CameraAimAssistTargetMode; use pocketmine\network\mcpe\protocol\types\camera\CameraSetInstructionEaseType; /** @@ -74,7 +78,8 @@ public static function fromJson(string $json) : CameraTimeline{ * 'steps' => [ * [ * 'type' => 'wait' | 'waitUntil' | 'shake' | 'stopShake' | 'clear' | 'set' | 'fade' | 'fov' - * | 'fog' | 'controlScheme' | 'target' | 'attachToEntity' | 'detachFromEntity', + * | 'fog' | 'controlScheme' | 'target' | 'attachToEntity' | 'detachFromEntity' + * | 'hud' | 'aimAssist', * // ... additional fields depending on type ... * ], * // ... @@ -162,8 +167,19 @@ public static function fromArray(array $data) : CameraTimeline{ $timeline->detachFromEntity(); break; + case 'hud': + self::addHudStep($timeline, $step); + break; + + case 'aimAssist': + self::addAimAssistStep($timeline, $step); + break; + default: - // Unknown step type: ignore for forwards-compatibility. + // Unknown step type: skipped for forwards-compatibility, but reported so typos are noticed. + Main::getInstance()->getLogger()->warning( + "Ignoring timeline step with unknown type " . var_export($type, true) + ); break; } } @@ -219,6 +235,25 @@ function(CameraSession $session) use ($step) : void{ ); } + if(isset($step['viewOffset']) && is_array($step['viewOffset']) && count($step['viewOffset']) >= 2){ + $builder->viewOffset(new Vector2( + (float) $step['viewOffset'][0], + (float) $step['viewOffset'][1] + )); + } + + if(isset($step['entityOffset']) && is_array($step['entityOffset']) && count($step['entityOffset']) >= 3){ + $builder->entityOffset(new Vector3( + (float) $step['entityOffset'][0], + (float) $step['entityOffset'][1], + (float) $step['entityOffset'][2] + )); + } + + if(isset($step['default']) && is_bool($step['default'])){ + $builder->setDefault($step['default']); + } + if($ease !== null){ $builder->ease($ease[0], $ease[1]); } @@ -228,6 +263,79 @@ function(CameraSession $session) use ($step) : void{ ); } + /** + * Adds a "hud" step to the timeline. + * + * Schema: preset (name of a preset registered in {@see HudPresetRegistry}, e.g. "DEFAULT" or "CLEAR") + * + * @param CameraTimeline $timeline + * @param array $step + */ + private static function addHudStep(CameraTimeline $timeline, array $step) : void{ + $preset = (string) ($step['preset'] ?? ''); + if(!HudPresetRegistry::isRegistered($preset)){ + throw new \InvalidArgumentException("'hud' step requires a registered 'preset', got '$preset'."); + } + + $timeline->add(static function(CameraSession $session) use ($preset) : void{ + $session->hud($preset); + }); + } + + /** + * Adds an "aimAssist" step to the timeline. + * + * Schema: + * - preset: aim assist preset identifier; omit it (or set `clear: true`) to clear the active aim assist + * - viewAngle: [horizontal, vertical] in degrees + * - distance: 1-16 blocks + * - targetMode: "angle" | "distance" + * - debug: bool + * + * @param CameraTimeline $timeline + * @param array $step + */ + private static function addAimAssistStep(CameraTimeline $timeline, array $step) : void{ + $targetMode = null; + if(isset($step['targetMode'])){ + $targetMode = match(strtolower((string) $step['targetMode'])){ + 'angle' => CameraAimAssistTargetMode::ANGLE, + 'distance' => CameraAimAssistTargetMode::DISTANCE, + default => throw new \InvalidArgumentException( + "Invalid aim assist target mode: " . var_export($step['targetMode'], true) + ) + }; + } + $clear = ($step['clear'] ?? false) === true || !isset($step['preset']); + $viewAngle = isset($step['viewAngle']) && is_array($step['viewAngle']) && count($step['viewAngle']) >= 2 + ? [(float) $step['viewAngle'][0], (float) $step['viewAngle'][1]] : null; + $distance = isset($step['distance']) ? (float) $step['distance'] : null; + + $timeline->add( + static function(CameraSession $session) use ($step, $clear, $targetMode, $viewAngle, $distance) : void{ + $builder = $session->aimAssist(); + if($clear){ + $builder->clear(); + }else{ + $builder->preset((string) $step['preset']); + } + if($viewAngle !== null){ + $builder->viewAngle($viewAngle[0], $viewAngle[1]); + } + if($distance !== null){ + $builder->distance($distance); + } + if($targetMode !== null){ + $builder->targetMode($targetMode); + } + if(($step['debug'] ?? false) === true){ + $builder->debug(); + } + $builder->send(); + } + ); + } + /** * Adds a "fade" step to the timeline. * From d747483f020745757a609468468afc09b095d88e Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:01:35 +0000 Subject: [PATCH 08/11] feat(marker): allow restricting who can see a camera marker Markers were always spawned to everyone. Add CameraMarkerEntity::setViewers() / CameraMarker::setViewers() and an optional $viewers argument to Camera::spawnMarker() so markers can be shown only to chosen players (e.g. admins editing a cutscene). Also fail with a clear LogicException instead of an uninitialized property error when a marker is spawned before setSkin(). --- src/kim/present/cameraapi/Camera.php | 4 +- .../present/cameraapi/marker/CameraMarker.php | 12 ++++ .../cameraapi/marker/CameraMarkerEntity.php | 56 ++++++++++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/kim/present/cameraapi/Camera.php b/src/kim/present/cameraapi/Camera.php index acf09e2..b5d639d 100644 --- a/src/kim/present/cameraapi/Camera.php +++ b/src/kim/present/cameraapi/Camera.php @@ -107,10 +107,11 @@ public static function loadTimeline(string $json) : CameraTimeline{ * * @param Location $location World coordinates (and yaw/pitch) where the marker should appear. * @param string|null $label Optional name tag shown above the marker. + * @param Player[]|null $viewers Players that may see the marker; null (default) shows it to everyone. * * @return CameraMarker Wrapper to move, rotate, apply to a session, or remove the marker. */ - public static function spawnMarker(Location $location, ?string $label = null) : CameraMarker{ + public static function spawnMarker(Location $location, ?string $label = null, ?array $viewers = null) : CameraMarker{ $world = $location->getWorld(); self::ensureChunkLoaded($world, $location); @@ -120,6 +121,7 @@ public static function spawnMarker(Location $location, ?string $label = null) : $entity->setNameTag($label); } + $entity->setViewers($viewers); $entity->spawnToAll(); return new CameraMarker($entity); diff --git a/src/kim/present/cameraapi/marker/CameraMarker.php b/src/kim/present/cameraapi/marker/CameraMarker.php index 4b94f53..ad12c5d 100644 --- a/src/kim/present/cameraapi/marker/CameraMarker.php +++ b/src/kim/present/cameraapi/marker/CameraMarker.php @@ -132,6 +132,18 @@ public function setOnClick(?\Closure $onClick) : self{ return $this; } + /** + * Restricts which players can see the marker (e.g. only the administrators editing a cutscene). + * + * @param Player[]|null $players The players that may see the marker; null to show it to everyone again. + * + * @return self For chaining. + */ + public function setViewers(?array $players) : self{ + $this->entity->setViewers($players); + return $this; + } + /** * Applies this marker's pose to a CameraSession. * diff --git a/src/kim/present/cameraapi/marker/CameraMarkerEntity.php b/src/kim/present/cameraapi/marker/CameraMarkerEntity.php index 2e6580b..4945058 100644 --- a/src/kim/present/cameraapi/marker/CameraMarkerEntity.php +++ b/src/kim/present/cameraapi/marker/CameraMarkerEntity.php @@ -64,7 +64,7 @@ */ final class CameraMarkerEntity extends Entity implements NeverSavedWithChunkEntity{ - private static Skin $skin; + private static ?Skin $skin = null; /** * Sets the skin used for all camera marker entities (e.g. a transparent or minimal skin). @@ -78,6 +78,13 @@ public static function setSkin(Skin $skin) : void{ private UuidInterface $uuid; + /** + * Players allowed to see this marker, keyed by their UUID; null when everyone can see it. + * + * @var array|null + */ + private ?array $viewers = null; + /** @var null|\Closure(CameraMarkerEntity, Player): void $onAttack Callback to player attack (left-click) */ private ?\Closure $onAttack = null; @@ -117,6 +124,7 @@ public static function getNetworkTypeId() : string{ * @param Player $player The player who will see the marker. */ protected function sendSpawnPacket(Player $player) : void{ + $skin = self::$skin ?? throw new \LogicException("CameraMarkerEntity::setSkin() must be called before spawning markers"); $networkSession = $player->getNetworkSession(); $typeConverter = $networkSession->getTypeConverter(); @@ -125,7 +133,7 @@ protected function sendSpawnPacket(Player $player) : void{ $this->uuid, $this->id, $this->getNameTag(), - $typeConverter->getSkinAdapter()->toSkinData(self::$skin)) + $typeConverter->getSkinAdapter()->toSkinData($skin)) ])); $networkSession->sendDataPacket(AddPlayerPacket::create( @@ -158,6 +166,50 @@ protected function sendSpawnPacket(Player $player) : void{ $networkSession->sendDataPacket(PlayerListPacket::remove([PlayerListEntry::createRemovalEntry($this->uuid)])); } + /** + * Restricts which players can see this marker. + * + * Players who are no longer allowed to see it get the marker despawned, and allowed players in range get it + * spawned. Markers are visible to everyone by default. + * + * @param Player[]|null $players The players that may see the marker; null to show it to everyone. + */ + public function setViewers(?array $players) : void{ + $this->viewers = null; + if($players !== null){ + $this->viewers = []; + foreach($players as $player){ + $this->viewers[$player->getUniqueId()->toString()] = true; + } + } + + if($this->isClosed() || $this->isFlaggedForDespawn()){ + return; + } + foreach($this->hasSpawned as $player){ + if(!$this->canBeSeenBy($player)){ + $this->despawnFrom($player); + } + } + // spawnTo() skips players that already see the marker or do not have its chunk + foreach($this->getWorld()->getPlayers() as $player){ + $this->spawnTo($player); + } + } + + /** + * Whether the given player is allowed to see this marker (see {@see self::setViewers()}). + */ + public function canBeSeenBy(Player $player) : bool{ + return $this->viewers === null || isset($this->viewers[$player->getUniqueId()->toString()]); + } + + public function spawnTo(Player $player) : void{ + if($this->canBeSeenBy($player)){ + parent::spawnTo($player); + } + } + public function attack(EntityDamageEvent $source) : void{ // Prevent taking damage / being destroyed by hits. $source->cancel(); From cfc00debb1082a906ff6a2d00f9c271faa3d932e Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:02:05 +0000 Subject: [PATCH 09/11] feat(camera): let positionLocal()/facingLocal() follow the player's pitch Both methods only used the yaw. Add an optional $followPitch flag that tilts the forward/up axes by the player's pitch (default false keeps the old behavior) and document that offsets are relative to the player's feet. --- .../camera/builder/CameraSetBuilder.php | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php b/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php index b1d873b..22cddd4 100644 --- a/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php +++ b/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php @@ -140,11 +140,16 @@ public function positionOffset(Vector3 $offset) : self{ * - Y: up (+) / down (-) * - Z: forward (+) / backward (-) * - * @param Vector3 $offset Local-space offset. + * The offset is applied relative to the player's feet position. Only the player's yaw is used by default, so + * "forward" stays horizontal; pass `$followPitch = true` to also tilt the Y/Z axes by the player's pitch + * (e.g. to place the camera along the line of sight). + * + * @param Vector3 $offset Local-space offset. + * @param bool $followPitch Whether the offset also follows the player's pitch. * * @return self */ - public function positionLocal(Vector3 $offset) : self{ + public function positionLocal(Vector3 $offset, bool $followPitch = false) : self{ $player = $this->session->getPlayer(); if($player === null || !$player->isConnected()){ return $this; @@ -152,7 +157,12 @@ public function positionLocal(Vector3 $offset) : self{ $location = $player->getLocation(); $basePos = new Vector3($location->getX(), $location->getY(), $location->getZ()); - $this->cameraPosition = $this->calculateLocalPosition($basePos, $location->getYaw(), $offset); + $this->cameraPosition = $this->calculateLocalPosition( + $basePos, + $location->getYaw(), + $offset, + $followPitch ? $location->getPitch() : 0.0 + ); return $this; } @@ -247,11 +257,12 @@ public function facingOffset(Vector3 $offset) : self{ * * Uses the same local axes and rotation rules as {@see positionLocal()}. * - * @param Vector3 $offset Local-space offset. + * @param Vector3 $offset Local-space offset. + * @param bool $followPitch Whether the offset also follows the player's pitch. * * @return self */ - public function facingLocal(Vector3 $offset) : self{ + public function facingLocal(Vector3 $offset, bool $followPitch = false) : self{ $player = $this->session->getPlayer(); if($player === null || !$player->isConnected()){ return $this; @@ -259,7 +270,12 @@ public function facingLocal(Vector3 $offset) : self{ $location = $player->getLocation(); $basePos = new Vector3($location->getX(), $location->getY(), $location->getZ()); - $this->facingPosition = $this->calculateLocalPosition($basePos, $location->getYaw(), $offset); + $this->facingPosition = $this->calculateLocalPosition( + $basePos, + $location->getYaw(), + $offset, + $followPitch ? $location->getPitch() : 0.0 + ); return $this; } @@ -317,46 +333,43 @@ public function setDefault(bool $value = true) : self{ * X = -cos(deg2rad(yaw)) * Z = -sin(deg2rad(yaw)) * + * With a pitch, forward becomes (-sin(yaw)cos(pitch), -sin(pitch), cos(yaw)cos(pitch)) and up becomes + * (-sin(yaw)sin(pitch), cos(pitch), cos(yaw)sin(pitch)). + * * @param Vector3 $basePos Player world position. * @param float $yaw Player yaw in degrees. * @param Vector3 $offset Local-space offset. + * @param float $pitch Player pitch in degrees that tilts the forward/up axes (0 keeps them horizontal/vertical). * * @return Vector3 World-space position. */ - private function calculateLocalPosition(Vector3 $basePos, float $yaw, Vector3 $offset) : Vector3{ + private function calculateLocalPosition(Vector3 $basePos, float $yaw, Vector3 $offset, float $pitch = 0.0) : Vector3{ $rad = deg2rad($yaw); + $sinYaw = sin($rad); + $cosYaw = cos($rad); + $pitchRad = deg2rad($pitch); + $sinPitch = sin($pitchRad); + $cosPitch = cos($pitchRad); - $forwardX = -sin($rad); - $forwardZ = cos($rad); + // The right vector stays horizontal; forward and up are tilted by the pitch (identity when pitch is 0) + $rightX = -$cosYaw; + $rightZ = -$sinYaw; - $rightX = -cos($rad); - $rightZ = -sin($rad); + $forwardX = -$sinYaw * $cosPitch; + $forwardY = -$sinPitch; + $forwardZ = $cosYaw * $cosPitch; - $dx = $rightX * $offset->x + $forwardX * $offset->z; - $dy = $offset->y; - $dz = $rightZ * $offset->x + $forwardZ * $offset->z; + $upX = -$sinYaw * $sinPitch; + $upY = $cosPitch; + $upZ = $cosYaw * $sinPitch; return new Vector3( - $basePos->x + $dx, - $basePos->y + $dy, - $basePos->z + $dz + $basePos->x + $rightX * $offset->x + $upX * $offset->y + $forwardX * $offset->z, + $basePos->y + $upY * $offset->y + $forwardY * $offset->z, + $basePos->z + $rightZ * $offset->x + $upZ * $offset->y + $forwardZ * $offset->z ); } - /** - * Makes the client drop the "ignore starting values" component of the preset (protocol field - * `removeIgnoreStartingValuesComponent`), so values the preset leaves unset are taken from the instruction - * instead of the camera's starting values. - * - * @param bool $remove - * - * @return self - */ - public function removeIgnoreStartingValues(bool $remove = true) : self{ - $this->removeIgnoreStartingValues = $remove; - return $this; - } - /** * Builds the CameraSetInstruction object. * From 67aa68d45a13d16c0872c8e47d92f59f3ac1d0bd Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:02:06 +0000 Subject: [PATCH 10/11] chore: require PocketMine-MP 5.42 and lint PHP syntax in CI - plugin.yml api 5.0.0 -> 5.42.0: the spline instruction and rotation easing used by the builders need BedrockProtocol 56 (Bedrock 1.26.10), which first shipped with 5.42. - Add a workflow that runs php -l on every source file for pushes to main and pull requests. --- .github/workflows/lint.yml | 22 ++++++++++++++++++++++ plugin.yml | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..567c997 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + +jobs: + php-lint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Set up PHP 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Check PHP syntax + run: find src -name '*.php' -print0 | xargs -0 -n1 php -l diff --git a/plugin.yml b/plugin.yml index 4fc8d31..3f711cc 100644 --- a/plugin.yml +++ b/plugin.yml @@ -2,7 +2,7 @@ name: CameraAPI main: kim\present\cameraapi\Main version: 1.0.0 -api: [ 5.0.0 ] +api: [ 5.42.0 ] author: PresentKim description: A simple camera API plugin ... From 1f5e1345d40f032827d5607ae5125a8467226ae0 Mon Sep 17 00:00:00 2001 From: PresentKim Date: Wed, 7 Oct 2026 13:02:42 +0000 Subject: [PATCH 11/11] docs(readme): document the new APIs and fix the aim-assist examples Cover reapply()/state getters, actor priorities and the aim assist toggle event, marker viewers, the pitch option of the local-space helpers, new JSON steps, the audio listener default and the PocketMine-MP 5.42 requirement. The aim-assist examples referenced VanillaAimAssistPresetIds and ->action(), which do not exist; use DefaultAimAssistPresetIds and clear(). --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 35094cb..c5798f8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ ### Installation & Requirements - **Server** - - PocketMine-MP `5.0.0` or higher. + - PocketMine-MP `5.42.0` or higher (`plugin.yml` `api`). The spline and rotation-easing packets need BedrockProtocol 56 (Bedrock 1.26.10). - **Installation** - Clone this repository or place the `CameraAPI` plugin folder into your PMMP server's `plugins` directory. - Restart the server; the plugin will load automatically according to `plugin.yml`. @@ -131,6 +131,10 @@ The session keeps camera context per player and provides builders and utility me - `detachFromEntity() : self` – detach camera from the current entity (see §2.8). - `hud(HudPreset|string $presetOrName) : self` – apply a HUD preset by instance or registry name (see §6). - `aimAssist() : AimAssistBuilder` – configure and send a camera aim‑assist packet (see §7.4). + - `aimAssistActorPriority() : AimAssistActorPriorityBuilder` – override the aim‑assist priority of single actors (see §7.5). + - `isClientAimAssistAllowed() : ?bool` – aim assist setting last reported by the client (null until it reports; see §7.5). + - `reapply() : self` – send the remembered camera / FOV / target / attachment / HUD / control scheme / fog state again (see §2.9). + - `isTimelinePlaying() : bool` / `getWaitingSignal() : ?string` – query the running timeline (see §3). - `spline() : CameraSplineBuilder` (**deprecated**, do not use in production) - `shake(float $intensity = 0.5, float $duration = 1.0, int $type = CameraShakePacket::TYPE_POSITIONAL) : self` - `stopShake(int $type = CameraShakePacket::TYPE_POSITIONAL) : self` @@ -172,15 +176,16 @@ $session->set() - Use `CameraSetInstructionEaseType` constants (e.g. `EaseType::LINEAR`). - `position(Vector3 $position) : self` - `positionOffset(Vector3 $offset) : self` – world-space offset from the player's current position. - - `positionLocal(Vector3 $offset) : self` – local-space offset relative to the player's view (X: right/left, Y: up/down, Z: forward/backward). + - `positionLocal(Vector3 $offset, bool $followPitch = false) : self` – local-space offset relative to the player's view (X: right/left, Y: up/down, Z: forward/backward). - `rotation(float $pitch, float $yaw) : self` - `rotationTo(Vector3 $target) : self` – compute and set rotation so the camera at the current position looks at the given world-space target. - `facing(Vector3 $position) : self` - `facingOffset(Vector3 $offset) : self` – world-space offset for the target the camera should look at. - - `facingLocal(Vector3 $offset) : self` – local-space offset for the facing target, using the same convention as `positionLocal()`. + - `facingLocal(Vector3 $offset, bool $followPitch = false) : self` – local-space offset for the facing target, using the same convention as `positionLocal()`. - `viewOffset(Vector2 $offset) : self` - `entityOffset(Vector3 $offset) : self` - `setDefault(bool $value = true) : self` + - `removeIgnoreStartingValues(bool $remove = true) : self` – sets the protocol's `removeIgnoreStartingValuesComponent` flag. - `send() : CameraSession` **World vs local offsets** @@ -190,6 +195,7 @@ $session->set() - X: right (+) / left (-) - Y: up (+) / down (-) - Z: forward (+) / backward (-) + - The offset is relative to the player's **feet** position. By default only the player's yaw is used (forward stays horizontal); pass `$followPitch = true` to tilt the forward/up axes by the player's pitch as well. #### 2.2 Screen Fade: `fade() : CameraFadeBuilder` @@ -346,6 +352,23 @@ $session->detachFromEntity(); --- +#### 2.9 State tracking: `reapply()` + +The session remembers what it sent to the client: the active camera `set` instruction, FOV, target, the attached +entity, the last HUD preset (applied through the plugin), the control scheme and the fog stack. They are cleared again +by `clear()`, a FOV clear, removing the target or detaching. + +- Getters: `getCurrentSet()`, `getCurrentFov()`, `getCurrentTarget()`, `getAttachedEntityId()`, `getCurrentHud()`, + `getCurrentControlScheme()`. +- `reapply()` sends that state again, e.g. after the client may have lost it (respawn, dimension change). Eases are + dropped so nothing animates twice. Nothing is reapplied automatically; call it from your own event handler. + +```php +public function onRespawn(PlayerRespawnEvent $event) : void{ + Camera::of($event->getPlayer())->reapply(); +} +``` + ### 3. `CameraTimeline` (Cutscenes / Sequences) `CameraTimeline` lets you queue multiple camera instructions in **time order** and play them as a cutscene. @@ -538,6 +561,20 @@ This is useful if non-programmers (builders / designers) need to tweak cutscenes { "type": "detachFromEntity" } ``` +- `hud` – apply a HUD preset registered in `HudPresetRegistry` (e.g. `DEFAULT`, `CLEAR`); unknown names fail when loading: + + ```json + { "type": "hud", "preset": "CLEAR" } + ``` + +- `aimAssist` – activate an aim‑assist preset, or clear it when `preset` is omitted / `"clear": true`. Optional `viewAngle` `[horizontal, vertical]`, `distance` (1–16), `targetMode` (`"angle"` / `"distance"`) and `debug`: + + ```json + { "type": "aimAssist", "preset": "cameraapi:aim_assist_entity_only", "distance": 12 } + ``` + +`set` steps also accept `viewOffset` `[x, y]`, `entityOffset` `[x, y, z]` and `default` (bool). Steps with an unknown `type` are skipped, and a warning is written to the server log. + **Full example – `boss_intro.json`** ```json @@ -632,6 +669,10 @@ use kim\present\cameraapi\camera\preset\CameraPresetRegistry; $id = CameraPresetRegistry::getIdByName("myplugin:topdown"); // int|null ``` +`CameraPresetBuilder::setAudioListenerType()` is unset (`null`) by default, so a preset inherits the audio listener type +of its parent preset. Set `CameraPreset::AUDIO_LISTENER_TYPE_PLAYER` / `AUDIO_LISTENER_TYPE_CAMERA` explicitly if you +want a fixed value. + `CameraSetBuilder::preset()` internally uses `getIdByName()`, so in most cases you only need to pass the preset name string. @@ -643,11 +684,13 @@ Camera markers are small in-world entities you can spawn to define camera positi fake players (with a plugin-supplied skin), support yaw/pitch from the spawn location, and can have an interact button and callbacks for left-click (attack) and right-click (interact). Use them for map creation or cutscene keyframes. -- **Spawning**: `Camera::spawnMarker(Location $location, ?string $label = null) : CameraMarker` +- **Spawning**: `Camera::spawnMarker(Location $location, ?string $label = null, ?array $viewers = null) : CameraMarker` - `$location` – world position and rotation (yaw/pitch) for the marker; e.g. use the player's location (optionally offset to eye height) so the marker faces the same direction. + - `$viewers` – optional list of `Player`s that may see the marker (e.g. only admins editing a cutscene); `null` shows it to everyone. - **Methods on `CameraMarker`** - `setPosition(Vector3 $pos)`, `lookAt(Vector3 $target)`, `setNameTag(string $name)`, `setInteractButton(string $buttonText)` + - `setViewers(?array $players)` – change who can see the marker later (`null` = everyone). - `setOnAttack(?\Closure $onAttack)` – callback when a player left-clicks the marker (e.g. remove it). - `setOnClick(?\Closure $onClick)` – callback when a player right-clicks the marker (e.g. apply marker to camera and play a timeline). - `applyToSession(CameraSession $session, ?int $easeType = null, ?float $easeDuration = null)` – sets the camera to the marker's position and rotation (vanilla FREE preset). Optionally pass an ease type (e.g. `CameraSetInstructionEaseType::LINEAR`) and duration in seconds for a smooth transition. @@ -869,7 +912,7 @@ To explicitly re‑send the list or a single preset to a player: ```php use kim\present\cameraapi\aimassist\AimAssistPresetRegistry; -use kim\present\cameraapi\aimassist\VanillaAimAssistPresetIds; +use kim\present\cameraapi\aimassist\DefaultAimAssistPresetIds; use pocketmine\player\Player; function syncAimAssist(Player $player) : void{ @@ -975,12 +1018,12 @@ directly. Instead, use the `CameraSession::aimAssist()` builder so you can start ```php use kim\present\cameraapi\Camera; -use kim\present\cameraapi\aimassist\VanillaAimAssistPresetIds; +use kim\present\cameraapi\aimassist\DefaultAimAssistPresetIds; // Enable the built-in entity-only aim-assist preset with default angle/distance $session = Camera::of($player); $session->aimAssist() - ->preset(VanillaAimAssistPresetIds::ENTITY_ONLY) + ->preset(DefaultAimAssistPresetIds::ENTITY_ONLY) ->send(); ``` @@ -992,24 +1035,36 @@ use kim\present\cameraapi\aimassist\VanillaAimAssistPresetIds; use pocketmine\network\mcpe\protocol\types\camera\CameraAimAssistTargetMode; Camera::of($player)->aimAssist() - ->preset(VanillaAimAssistPresetIds::MINECRAFT_DEFAULT) - ->viewAngle(60.0, 45.0) // yaw/pitch half-angles - ->distance(24.0) // max targeting distance + ->preset(DefaultAimAssistPresetIds::MINECRAFT_DEFAULT) + ->viewAngle(60.0, 45.0) // horizontal/vertical angle (> 0) + ->distance(12.0) // max targeting distance (1-16) ->targetMode(CameraAimAssistTargetMode::ANGLE) // or ::DISTANCE ->send(); ``` -To clear an active aim-assist configuration, use `CameraAimAssistActionType::CLEAR`: +To clear an active aim-assist configuration, use `clear()` (or `preset(null)`): ```php use kim\present\cameraapi\Camera; -use pocketmine\network\mcpe\protocol\types\camera\CameraAimAssistActionType; Camera::of($player)->aimAssist() - ->action(CameraAimAssistActionType::CLEAR) + ->clear() ->send(); ``` +`distance()` only accepts 1–16 blocks, `viewAngle()` needs positive angles, and `send()` throws +`\InvalidArgumentException` for a preset that is not registered in `AimAssistPresetRegistry`. + +#### 7.5 Actor priorities and the client's aim‑assist toggle + +- `Camera::of($player)->aimAssistActorPriority()->add($presetId, $categoryId, $actorId, $priority)->send()` overrides + the priority of an actor listed in a registered category. The builder resolves the preset/category/actor positions + that the protocol uses from `AimAssistPresetRegistry` (see `getPresetIndex()` / `getCategoryIndex()`). This packet + has not been verified against a real client yet. +- When a client reports that its aim assist was switched on or off, the plugin stores it + (`CameraSession::isClientAimAssistAllowed()`) and calls `CameraAimAssistToggleEvent` + (`getPresetId()`, `getActionType()`, `isAimAssistAllowed()`). + --- ## Implementation & Architecture Overview