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/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 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 ... 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/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/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( '', 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/camera/builder/CameraSetBuilder.php b/src/kim/present/cameraapi/camera/builder/CameraSetBuilder.php index 3e41050..22cddd4 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 @@ -139,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; @@ -151,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; } @@ -246,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; @@ -258,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; } @@ -316,29 +333,40 @@ 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 ); } @@ -366,7 +394,7 @@ public function build() : CameraSetInstruction{ $this->viewOffset, $this->entityOffset, $this->default, - false + $this->removeIgnoreStartingValues ); } 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; } 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/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/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(); diff --git a/src/kim/present/cameraapi/session/CameraSession.php b/src/kim/present/cameraapi/session/CameraSession.php index eb2cc1f..de8613a 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; @@ -40,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; @@ -62,6 +67,15 @@ final class CameraSession{ private ?CameraTimeline $pausedTimeline = null; /** @var list */ 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. @@ -241,6 +255,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. @@ -344,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; } @@ -366,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). * 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. *