diff --git a/guides/Camera and sound/audio.md b/guides/Camera and sound/audio.md index 033a8b1..c1d9e13 100644 --- a/guides/Camera and sound/audio.md +++ b/guides/Camera and sound/audio.md @@ -27,7 +27,7 @@ function thunderAt(x, y, z) { The server sends a sound once, to the players in range as it starts. A player who arrives or joins afterwards does not hear it, and a player still on a loading screen misses it. -Sounds ignore [virtual worlds](/guides/core-concepts/virtual-worlds/#what-ignores-worlds): a player within range hears a sound whatever world they are in. To keep a sound to one world, send it to that world's players and [play it on their clients](#playing-sounds-on-the-client). +`Audio.playSoundOnPlayer` reaches only players who share that player's [virtual world](/guides/core-concepts/virtual-worlds/) and map. `Audio.playSoundAt` reaches players in any world unless you give it a `virtualWorld`, a `map` (an [`areaId`](/guides/core-concepts/virtual-worlds/#worlds-and-maps)) or both; the one you leave out is world 0 or Overland. Then only players in that world of that map hear it, and a `range` of 0 reaches every one of them. ## Sounds that keep playing @@ -48,14 +48,12 @@ On the server, `Audio.stopAllSounds()` stops every sound on every client, the ga A client's `Audio.playSoundAt(event, x, y, z, options)` plays a sound only its own player hears, from a point. `Audio.playSound(event, options)` plays one on the player's own character, at full volume. Both take `duration` and `stopEvent`, and return an id for `Audio.stopSound`, or 0 when the game refused the sound. `Audio.stopOnSelf()` stops everything playing on the player's character. -This pair plays a sound for the players in one world only: +This pair plays a sound from a point for the players you pick, such as one team: ```js // server/main.js -function playInWorld(world, event, x, y, z) { - for (const player of PlayerManager.getAll()) { - if (player.virtualWorld === world) player.emit("sound:play", JSON.stringify({ event, x, y, z })); - } +function playForTeam(team, event, x, y, z) { + for (const player of team) player.emit("sound:play", JSON.stringify({ event, x, y, z })); } ``` diff --git a/guides/Camera and sound/camera.md b/guides/Camera and sound/camera.md index 6640a1b..4a518c2 100644 --- a/guides/Camera and sound/camera.md +++ b/guides/Camera and sound/camera.md @@ -1,6 +1,6 @@ --- title: Camera -description: Take over the player's view with a camera of your own, move it in shots, paths and orbits, fade the screen, and hand the view back. +description: Take over the player's view with a camera of your own, move it in shots, paths and orbits, steer it with the mouse, fade the screen, and hand the view back. sidebar: order: 700 --- @@ -113,6 +113,34 @@ function circlePlayer() { An orbit, or `lookAt`, leaves the camera aimed at its point after the orbit stops, after `stop` and even after `restore`. The aim then overrides the rotation of every later shot until `Camera.clearLookAt()` lifts it, so clear it before you set up a new shot, as the samples above do. +## Looking around with the mouse + +While your camera is active, the game's own camera stops reading the mouse, so the player cannot look around. `Camera.getLookInput()` returns how far the mouse has looked since the last call, as `pitch` and `yaw` in degrees, and starts counting again from zero. Positive `pitch` looks up and positive `yaw` turns right. Read it on a timer and turn your camera by it, for a free camera: + +```js +// client/main.js +function startFreeCamera() { + const view = Camera.capture(); + if (!view) return; + Camera.clearLookAt(); + Camera.activate(view, { duration: 0 }); + + const rotation = { ...view.rotation }; + const timer = setInterval(() => { + if (!Camera.active) { + clearInterval(timer); + return; + } + const look = Camera.getLookInput(); + rotation.yaw += look.yaw; + rotation.pitch = Math.max(-89, Math.min(89, rotation.pitch + look.pitch)); + Camera.setPose({ position: Camera.pose.position, rotation }); + }, 16); +} +``` + +It reads zero while your camera is not active, while the cursor shows, as it does in chat, with a web view focused or with the [controls locked](/guides/user-interface/keys-and-menus/#locking-the-controls), and while a game menu is open. Movement in those moments is dropped, not saved for later, so the view does not jump when they end. Each read clears what it returns, so read it from one place. + ## Lens `Camera.setLens(options)` changes the active camera's lens: `fov` is the horizontal field of view in degrees, between 0 and 180, and `focalLength`, `aperture` and `postProcessBlend` are there for cinematic shots. A view passed to `activate` or `animateTo`, or a keyframe, can carry a `lens` too. `Camera.lens` reads the lens back. diff --git a/guides/Core concepts/events.md b/guides/Core concepts/events.md index 661aea3..994cfb0 100644 --- a/guides/Core concepts/events.md +++ b/guides/Core concepts/events.md @@ -158,24 +158,33 @@ The declarations type the handlers of the events in these tables, so an editor k | `playerDownedEnd` | `(player)` | A player recovers from the downed state or respawns | | `playerHoldEnded` | `(player, owner, reason)` | A server-owned hold ends; `reason` identifies why | | `playerHoldStrained` | `(player, owner, distanceCm)` | A held player continues reporting positions beyond the hold boundary | +| `playerMoveEnded` | `(player, owner, reason)` | A `player.moveTo` or `player.follow` walk ends. See [Walking and seating players](/guides/players/walking-players/#when-a-walk-ends) | | `playerWandChanged` | `(player)` | The player's wand changes | +| `playerSpellCast` | `(player, spell, cast)` | A player casts a [server-defined spell](/guides/players/custom-spells/#casting); `spell` is its id and `cast` its `aimYaw` and `wand` tip, as the caster's game reports them | | `playerGearChanged` | `(player)` | What the player wears changes | | `worldReady` | `(player)` | The player's world, pawn, and replication are ready | | `loadingFinished` | `(player)` | The player's load screen is dismissed after `worldReady` | | `playerInventoryChanged` | `(player, items)` | The player's reported native inventory changes; `items` is the full native inventory | | `playerInventoryNativeDelta` | `(player, delta)` | A positive native acquisition not yet represented by the authoritative inventory is reported; the first report after connecting establishes a baseline and does not fire this event | -| `playerInventoryUpdated` | `(player, items, revision)` | The authoritative inventory model changes | +| `playerInventoryUpdated` | `(player, items, revision)` | The authoritative inventory model changes, by a script or by gear and mounts the player changes in game | | `playerInventoryConsumed` | `(player, item, remaining)` | An accepted native consumable use updates authoritative inventory | | `playerAppearanceChanged` | `(player, look, revision)` | The server accepts a new look from the player's game or `player.setLook`; `look` is the checked [look](/guides/players/appearance/#the-look) and `revision` increases for the connection | | `playerTeleportComplete` | `(player, requestId, status, completion)` | A `player.teleport` request finishes; `completion` includes the normalized request, client-observed final transform, and ground-snap result | | `playerFastTravelComplete` | `(player, requestId, status)` | A `player.fastTravel` request finishes or times out | +| `playerMapTravelComplete` | `(player, requestId, status, completion)` | A `player.travelToMap` or `player.travelToPlace` request finishes, once the player can play in the new map, or times out. See [Travel between places](/guides/players/travel/#loading-another-map) | | `npcDied` | `(npcId, enemyId, killerId, damagerIds)` | A server-spawned NPC dies; `killerId` is the last player to damage it, or `null`, and `damagerIds` every player who did. See [Fighting NPCs](/guides/pve/fighting-npcs/#damage-and-death) | | `npcDamaged` | `(npcId, attackerId, amount)` | A player reports damage to a server-spawned NPC | +| `npcWaypoint` | `(npc, index, lap)` | An NPC on a path reaches `waypoints[index]`, before its wait; `lap` counts completed laps. See [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/#path-events) | +| `npcMoveEnded` | `(npc, reason)` | An NPC's path ends: `"arrived"`, `"stopped"`, `"replaced"` or `"unreachable"` | | `beastCared` | `(beast, player, kind)` | A player pets or feeds a beast; return `false` synchronously to refuse it | | `beastCaptureStart` | `(beast, player)` | A player's Nab-Sack starts drawing in a beast they may catch; return `false` synchronously to make it let go. See [Catching beasts](/guides/npcs-and-creatures/catching-beasts/#refereeing-a-catch) | | `beastCaptureRequest` | `(beast, player)` | A player's Nab-Sack finished on a beast and the catch waits for the server; return `false` synchronously to refuse it | | `beastCaptured` | `(player, captured)` | A catch landed: the beast is in the player's bag and has left the world | | `beastReleased` | `(player, released, at)` | A player released a beast from their Field Guide; `at` is where they stood | +| `beastWaypoint` | `(beast, index, lap)` | A beast on a path reaches `waypoints[index]`, before its wait | +| `beastMoveEnded` | `(beast, reason)` | A beast's path or follow ends: `"arrived"`, `"stopped"`, `"replaced"`, `"unreachable"` or `"targetGone"` | +| `characterWaypoint` | `(character, index, lap)` | A character on a path reaches `waypoints[index]`, before its wait | +| `characterMoveEnded` | `(character, reason)` | A character's path ends: `"arrived"`, `"stopped"` or `"replaced"` | | `campActivated` | `(campId)` | A [camp](/guides/encounters/camps/)'s enemies spawn | | `campCleared` | `(campId)` | Every enemy of an active camp is dead | | `worldEventStarted` | `(spotId, kind)` | A [world event](/guides/encounters/world-events/) starts | @@ -185,6 +194,9 @@ The declarations type the handlers of the events in these tables, so an editor k | `zoneExit` | `(zone, entity, reason, entityId)` | An entity leaves a zone; `reason` is `"left"` or `"gone"` | | `doorUnlockRequest` | `(player, door)` | A player tries to open a lock the server holds shut, by key or Alohomora | | `lootableTaken` | `(player, lootableId, items)` | A player takes a server lootable; `items` is what it gave them | +| `trainSpawned` | `(t)` | `World.hogwartsExpress.spawn` stands the [Hogwarts Express](/guides/world/hogwarts-express/) at `t` | +| `trainStopped` | `(t)` | The train brakes to a stop at `t`, at the end of a `moveTo` or a `halt` | +| `trainEnded` | `(reason)` | The train is gone; `reason` is `"end"` when a departure left the track, `"despawned"` after `despawn()` or a replacing `spawn()` | | `housePointsChange` | `(house, points, previous)` | A house's points change | | `housePointsRequest` | `(player, request)` | A player's game asks to change house points; return `false` synchronously to refuse it | | `sanctuaryChanged` | `(id, revision, editor)` | An editor changes a [Room of Requirement](/guides/room-of-requirement/rooms/) room | @@ -225,7 +237,7 @@ These events are delivered only inside the client runtime. Receiving a native cl | `resourceStop` | `(resourceName)` | A client JavaScript resource is about to stop | | `chatMessage` | `({ author, text, color })` | A chat message arrives from the server | | `chatSend` | `(text)` | The local player submits text through the chat overlay | -| `spellCast` | `(spellPath)` | The local player casts a spell; the path identifies its spell record | +| `spellCast` | `(spellPath)` | The local player casts a spell; the path identifies its spell record, or is the id of a [server-defined spell](/guides/players/custom-spells/#casting) | | `playerRegionChanged` | `(region)` | The local player enters a named region | | `creatorOpened` | `({})` | The character creator opens | | `creatorConfirmed` | `({ first, last })` | The character creator closes after confirmation | @@ -235,9 +247,12 @@ These events are delivered only inside the client runtime. Receiving a native cl | `spellAssignment` | `({ source, loadout, slot })` | A spell is assigned to a slot, from the game's menu or a script | | `spellLearningFinished` | `(result)` | A spell lesson the resource started finishes; it grants no spell | | `minimapChanged` | `(state, previous)` | The [minimap](/guides/user-interface/map-markers/#the-minimap) switches map, floor or tile | +| `waypointChanged` | `(waypoint, previous)` | The local player places, moves or removes their [map waypoint](/guides/user-interface/map-markers/#the-players-waypoint); a removal reports `null` | +| `mailRead` | `(key, sender)` | The local player reads an owl post letter, theirs or the game's; `sender` is the sender's id, not their display name. A letter that loads or arrives already read raises nothing | | `pvpRelationChanged` | `(relation)` | The local player's [PvP relation](/guides/pvp/rules-and-relations/) to another player is resolved or changes | | `localDowned` | `()` | The local player kneels after a PvP defeat | | `localDownedEnd` | `({ wasDead })` | The local player gets up, from a kneel or a respawn | +| `anchorReleased` | `(reason, at)` | The local player's [seat anchor](/guides/players/walking-players/#seating-the-player) ends; `at` is the floor-level point they sat at | | `entityStateChange` | `(entity, key, value, previous)` | A replicated state write or removal arrives on the client; `value` is `undefined` for a removal | A `chatSend` handler may synchronously return `false` to prevent that line from being sent. An asynchronous return value cannot veto it. diff --git a/guides/Core concepts/virtual-worlds.md b/guides/Core concepts/virtual-worlds.md index 80b36f0..d5cd198 100644 --- a/guides/Core concepts/virtual-worlds.md +++ b/guides/Core concepts/virtual-worlds.md @@ -1,6 +1,6 @@ --- title: Virtual worlds -description: Split players and entities into separate copies of the world, how a player's world combines with the map they are in, and what ignores worlds. +description: Split players and entities into separate copies of the world, how a world combines with the map it is in, and what ignores worlds. sidebar: order: 230 --- @@ -33,52 +33,91 @@ The value reads back at once and takes effect on the next server tick. A player' ### Worlds and maps -The world you set is combined with the map the player is in. In Overland, the game's main map, world N is world N. Every other map, such as a dungeon, has worlds of its own: players there share a world with the others in the same map who have the same world number, and never meet anyone in another map. +A world is combined with the map it is in. In Overland, the game's main map, world N is world N. Every other map, such as a dungeon, has worlds of its own: players there share a world with the others in the same map who have the same world number, and never meet anyone in another map. So world N of a dungeon is one copy of it, and several groups can each have their own. The global world is global in every map. -During a loading screen between maps, each player is alone in a world of their own. In the Room of Requirement, each room is a world of its own, combined with the world number: see [Who sees whom](/guides/room-of-requirement/doors-and-visits/#who-sees-whom). +A map is named by the `areaId` that [`player.location()`](/guides/players/join-and-spawn/#putting-players-in-place) reports there, such as `"Overland"` or `"DeathlyHallows_Dungeon"`. A player keeps their world number when they change maps, and `player.virtualWorld` returns that number, not the combined world. -`player.virtualWorld` returns the number you set, not this combined world. +During a loading screen between maps, each player is alone in a world of their own. In the Room of Requirement, each room is a world of its own, combined with the world number: see [Who sees whom](/guides/room-of-requirement/doors-and-visits/#who-sees-whom). ## Placing entities -NPCs, beasts, world objects, effects and characters start in world 0, whoever created them. Move an entity into a player's world after creating it: +NPCs, beasts, world objects, effects and characters start in world 0 of Overland, whoever created them. `entity.setVirtualWorld(world, map)` moves one into a world of a map, where it meets the players in that world of that map. `map` is an `areaId`, with `"Overland"` or `""` for Overland; leave it out to keep the entity in the map it is in. `entity.virtualWorld` reads back the world number. + +This sends a party into its own copy of a dungeon, with guards waiting: ```js // server/main.js -function spawnArenaGuard(arena, x, y, z) { - const guard = NPC.create("Goblin_Grunt", x, y, z, "hostile"); - guard?.setVirtualWorld(arena); - return guard; +const DUNGEON = "DeathlyHallows_Dungeon"; +let nextRun = 1; + +function startRun(party, guardSpots) { + const run = nextRun++; + for (const spot of guardSpots) { + const guard = NPC.create("Goblin_Grunt", spot.x, spot.y, spot.z, "hostile"); + guard?.setVirtualWorld(run, DUNGEON); + } + for (const player of party) { + player.setVirtualWorld(run); + player.fastTravel("FT_DeathHallows"); + } + return run; } ``` -An entity's world is not combined with a map, so it lines up with a player's world only in Overland. In any other map no world number matches, and only the global world `4294967295` reaches players there. An effect attached to an entity does not take that entity's world; set it on the effect too. +Positions are in the map's own coordinates. Changing an entity's map does not move it: it stands at the same coordinates in the new map. An effect attached to an entity does not take that entity's world or map; set them on the effect too. -An NPC's or beast's AI runs on the client of a player who can see it, so in a world with no players it stands still until someone arrives. `NPC.create`'s `preferredOwnerId` is honoured only if that player can see world 0 when the NPC spawns. +`NPC.create` and `Beast.create` with a `preferredOwnerId` start the NPC or beast in that player's world and map. An NPC's or beast's AI runs on the client of a player who can see it, so in a world with no players it stands still until someone arrives. ### Showing an entity to one player `entity.setVisibleTo(player)` narrows who receives an entity to that one player, as long as they could see it anyway; it does not show an entity across worlds. `entity.setVisibleTo(null)` undoes it. The setting follows that player's connection, so after they reconnect nobody receives the entity until you set it again. Use it on NPCs and objects, not on players. +## Loot, zones and sounds + +These calls take a `virtualWorld` and a `map` in their options, the way `setVirtualWorld` takes them: + +| Call | With `virtualWorld` and `map` | +| --- | --- | +| [`Lootables.place(kind, x, y, z, yaw, options)`](/guides/world/loot/#placing-a-lootable) | Places the lootable in that world of that map | +| `Brewing.place(uid, registryId, x, y, z, yaw, options)` | Places the cauldron there | +| `Herbology.place(plotSize, x, y, z, options)` | Places the herb plot there | +| [`Zone.create(key, shape, options)`](/guides/encounters/zones/#creating-a-zone) | Watches that world of that map | +| [`Audio.playSoundAt(event, x, y, z, options)`](/guides/camera-and-sound/audio/#playing-sounds-from-the-server) | Plays only to the players in that world of that map | + +Without them, the first four use world 0 of Overland, and a sound reaches every player in range whatever their world. `Audio.playSoundOnPlayer` always keeps to that player's world and map. + +```js +// server/main.js +function stockVault(run, vault) { + const where = { virtualWorld: run, map: "DeathlyHallows_Dungeon" }; + Lootables.place("chest", vault.x, vault.y, vault.z, 0, { items: [{ itemId: "Edurus", count: 3 }], ...where }); + Zone.create(`dungeon-run:${run}:vault`, { type: "sphere", center: vault, radius: 800 }, where); +} +``` + +A herb plot's id comes from its coordinates alone, so a spot holds one plot across every world: placing another at the same spot, in any world, replaces it. + ## What ignores worlds Some things reach every player whatever their world. Filter them yourself where it matters: - `Chat.sendToAll` and `Events.emitAllClients` send to everyone. +- `Audio.playSoundForAll` plays to everyone, and `Audio.playSoundAt` without `virtualWorld` or `map` to everyone in range. - `PlayerManager.getAll()` and the other managers' `getAll()` return every player or entity. - `World.state` reaches every client. - `player.setVisible(false)` hides a player from everyone, in every world. ```js // server/main.js -function sendToWorld(world, text) { +function sendToWorld(world, map, text) { for (const player of PlayerManager.getAll()) { - if (player.virtualWorld === world) player.sendChat(text); + if (player.virtualWorld === world && player.location()?.areaId === map) player.sendChat(text); } } ``` ## Where to go next +- [Travel between places](/guides/players/travel/) for moving players into another map. - [Authority](/guides/core-concepts/authority/) for what the server owns and what clients only report. - [State bags](/guides/core-concepts/state-bags/) for per-entity state, which follows the same visibility. diff --git a/guides/Encounters/zones.md b/guides/Encounters/zones.md index a338b74..b920237 100644 --- a/guides/Encounters/zones.md +++ b/guides/Encounters/zones.md @@ -32,13 +32,15 @@ Shapes are in world centimetres: The options are: -- `virtualWorld`: the [world](/guides/core-concepts/virtual-worlds/) it watches, 0 by default. A zone matches entities in exactly that world. Players match only in Overland, since anywhere else their world is combined with their map. +- `virtualWorld` and `map`: the [world](/guides/core-concepts/virtual-worlds/#worlds-and-maps) it watches, and the map that world is in by its `areaId`. By default, world 0 of Overland. A zone matches entities in exactly that world of that map. `zone.virtualWorld` and `zone.map` read them back, with `""` for Overland. - `track`: which entities it notices, from `"player"`, `"npc"`, `"beast"`, `"worldObject"` and `"character"`, by default only players. A malformed shape or option throws. The server holds at most 4096 zones. Keys are shared by every resource, and creating a zone under a key in use replaces that zone, so start yours with your resource's name. `Zone.get(key)` returns a zone or `undefined`, `Zone.getAll()` lists them, and `Zone.destroy(key)` or `zone.destroy()` removes one. +Players cannot see a zone. To show them where one lies, send `zone.getShape()` to their clients and draw it with [Shapes](/guides/world/shapes/#showing-a-zone). + ## Coming and going `zoneEnter` fires with the zone and the entity that came in. `zoneExit` fires with the zone, the entity, a reason and the entity's id: the reason is `"left"` when it moved out, or out of the zone's world, and `"gone"` when it no longer exists, such as a player who disconnected, and then the entity is `null`. Every resource receives every zone's events, so check the key: diff --git a/guides/Hosting a server/configuration.md b/guides/Hosting a server/configuration.md index b683aa4..61ee0e4 100644 --- a/guides/Hosting a server/configuration.md +++ b/guides/Hosting a server/configuration.md @@ -57,6 +57,8 @@ HogwartsMP's own settings live under the `mod` object, so they can never collide | `autoRespawn` | `true` | Whether a dead player respawns on their own after a few seconds. See [Respawning](#respawning). | | `broomUpgradeTier` | `0` | The broom upgrades, from 0 to 3, every player starts with. Scripts change it per player with [`player.setBroomUpgradeTier`](/guides/mounts-and-races/brooms-and-mounts/#broom-upgrades-and-speed). | | `broomSpeedMultiplier` | `1` | How fast every player's broom flies, from 0.25 to 3, on top of its upgrades. Values outside the range are clamped. | +| `gearStatMultiplier` | `1` | How much of their worn gear's Offense, Defense and Cooldown every player gets, from 0 to 1; 0 makes gear cosmetic. Values outside the range are clamped. Scripts change it per player with [`player.setGearStatMultiplier`](/guides/players/inventory/#what-worn-gear-is-worth). | +| `gearTraits` | `true` | Whether the traits on every player's worn gear apply. Scripts change it per player with `player.setGearTraits`. | ### Validation @@ -119,6 +121,27 @@ The list starts empty, and it should stay empty until you have chosen and tested That is a fragment of `server.json`, not a complete file, and the placeholder has to be replaced. Several projects are separate strings in the same array. Do not use a URL, project name, slug or file ID, or an ID copied from another server without knowing what it installs. Set the array back to `[]` to require nothing. +### What a join does + +Every join checks the required mods with CurseForge. The player's game installs the ones it is missing and updates the rest to each project's latest file. You cannot pin a version: a file an author publishes reaches your players at their next join. The join screen shows how much has downloaded and how fast, both for your resources' files and for the mods. + +The game only changes its mods when it starts. When a join installs a mod, replaces an outdated copy or turns off a mod your server does not require, the game restarts and joins your server again by itself. Turned-off mods stay installed. While your server requires mods, any mod a player copied into the game's `Phoenix/Mods` folder by hand is moved to `Phoenix/HogwartsMP-ParkedMods` beside it. A server that requires none leaves those mods in place. + +### Rejected joins + +A required mod that cannot be checked, installed or updated turns the player away before the world loads. The message lists the failed mods first, each with its cause, and shows only its first few lines. The player's [client log](/guides/getting-started/debugging/#where-output-goes) holds every line. + +| Cause | What helps | +| --- | --- | +| CurseForge did not answer within a minute | Try again later. | +| No download progress for a minute | Check the player's connection, and try again. | +| Not enough disk space | Free space on the drive that holds the folder the message names. | +| The game cannot use its mods folder | Windows Controlled Folder Access or antivirus folder protection may be blocking `HogwartsMPLauncher.exe`, so allow it there. If Documents is on OneDrive, OneDrive must be running. | +| CurseForge lists the project as deleted, rejected, under review or needing changes | The game does not install such a project, so a player who does not have it yet cannot join. Remove it from the list. Inactive and abandoned projects still install. | +| The game could not remove or move a mod folder when it started | Quit the game, close whatever holds the folder or move it out of `Phoenix/Mods` by hand, and start again. | + +An install that is broken, or whose last attempt failed, is installed again at the next join, so players do not need to reinstall a mod by hand. + ## Respawning With `mod.autoRespawn` at its default of `true`, a dead player stands back up on their own a few seconds after dying. Set it to `false` and a dead player stays where they fell, still watching the world, until a script respawns them with `player.respawn()`. Game modes with revives, spectating or round-based play use this. diff --git a/guides/Hosting a server/run-a-server.md b/guides/Hosting a server/run-a-server.md index 2f3797d..9751e6b 100644 --- a/guides/Hosting a server/run-a-server.md +++ b/guides/Hosting a server/run-a-server.md @@ -8,7 +8,7 @@ sidebar: A HogwartsMP server is one executable plus a `server.json` configuration file and a `resources/` directory. The release ships the runtime only, with no game mode. Public servers run **HMP Foundations**, a complete resource pack that adds characters, inventory, banking, jobs, shops, administration and other common systems on top of a MySQL or MariaDB database. -This guide takes a fresh machine to a server players can join, then covers keeping it running and updating it. It is for server owners. Players only need the [player guide](https://github.com/hogwarts-mp/hosting/blob/main/PLAYER_GUIDE.md). +This guide takes a fresh machine to a server players can join, then covers keeping it running and updating it. It is for server owners. Players only need the [player guide](https://github.com/hogwarts-mp/hosting/blob/main/PLAYER_GUIDE.md) and their own PC copy of Hogwarts Legacy, from Steam or the Epic Games Store. A PC Game Pass copy also works, but its support is experimental. ## What you need diff --git a/guides/Mounts and races/brooms-and-mounts.md b/guides/Mounts and races/brooms-and-mounts.md index da69aa4..c538ca7 100644 --- a/guides/Mounts and races/brooms-and-mounts.md +++ b/guides/Mounts and races/brooms-and-mounts.md @@ -118,6 +118,12 @@ Events.on("hippogriff:fly", () => { `state()` on either reports the creature's `species`, whether the player is `mounted`, and for flying creatures whether they are `flying`. +## A rider's size and visibility + +A broom is drawn at its rider's size: [`player.setScale`](/guides/players/appearance/#size) resizes the broom a player rides along with them, in either mode and on every client. In flight it keeps a native broom's collision. A creature keeps its own size. + +`player.setVisible(false)` hides the broom or creature a player rides along with the player, the broom's flight trails included. A broom or creature they summon while hidden stays hidden too. + ## Keeping riders out To keep players off brooms and creatures in an arena, take the items away on the server while they are inside, and have their client dismount them and keep the game's tool wheel, where players summon them, shut: diff --git a/guides/NPCs and creatures/beasts-and-characters.md b/guides/NPCs and creatures/beasts-and-characters.md index c54fa3e..bf09ef3 100644 --- a/guides/NPCs and creatures/beasts-and-characters.md +++ b/guides/NPCs and creatures/beasts-and-characters.md @@ -1,11 +1,11 @@ --- title: Beasts and characters -description: Spawn tame beasts players can pet, feed and catch, decide who may care for them, and place stationary townspeople and vendors with nameplates. +description: Spawn tame beasts players can pet, feed and catch, decide who may care for them, and place townspeople, ghosts, house-elves and other characters with nameplates. sidebar: order: 420 --- -Besides enemies, the server can place two kinds of living thing: tame beasts, which wander and can be cared for, and characters, which stand still and never fight. +Besides enemies, the server can place tame beasts, which wander and can be cared for, and characters, which never fight. A character stands where you put it, or walks a path you give it. ## Beasts @@ -28,7 +28,7 @@ The species are the game's 13 creatures you can nurture and their young, such as | `color` | One of the species' colours | | `preferredOwnerId` | The player whose client should run its AI | -Like an NPC, a beast's AI runs on one nearby player's client, named by `beast.ownerId`, and a beast starts in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0. +Like an NPC, a beast's AI runs on one nearby player's client, named by `beast.ownerId`. A beast starts in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0, or, with `preferredOwnerId`, in that player's world and map. [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/#beasts) covers walking a beast, having it follow someone and what it does when idle. ### Care and happiness @@ -57,7 +57,7 @@ Players can catch a beast with the game's Nab-Sack once a script makes it catcha ## Characters -A character is a dressed, stationary human placed by the server: a vendor, a townsperson or a quest giver. It has no AI, no owner and no health. `Character.create(key, characterId, options)` creates one under a key of your choosing, and creating again with the same key replaces it: +A character is a figure placed by the server: a vendor, a townsperson, a ghost or a quest giver. It has no AI, no owner and no health. It stands where you put it until you give it a path, which [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/#characters) covers. `Character.create(key, characterId, options)` creates one under a key of your choosing, and creating again with the same key replaces it: ```js // server/main.js @@ -69,11 +69,16 @@ Character.create("wand-shop", "GerboldOllivander", { }); ``` -`characterId` must be one of the game's human characters that the Mod allows, such as `GerboldOllivander`; `Character.isAllowed(characterId)` tests one, and `Character.create` throws for any other. `Character.destroy(key)` removes a character by its key. +`characterId` must be one of the game's characters that the Mod allows; `Character.isAllowed(characterId)` tests one, and `Character.create` throws for any other. Characters come in two kinds of body: + +- A human id, such as `GerboldOllivander`, is built on the same body as a player's and dressed as that character. +- Other ids spawn the game's own body for that character: ghosts such as `NearlyHeadlessNick`, house-elves, goblins, centaurs, the game's crowd looks such as `T3TownspersonFemale1`, the statue busts, the Sorting Hat (`SortingHat`) and the Howler (`Howler`). The Mod switches off the AI, lock-on, interaction prompt and damage the game gives that body. + +Ids with no look of their own, such as portraits and talking objects, are refused. `Character.destroy(key)` removes a character by its key. | Option | Meaning | | --- | --- | -| `x`, `y`, `z`, `yaw` | Where it stands and which way it faces | +| `x`, `y`, `z`, `yaw` | Where it stands, as a player's position, and which way it faces | | `scale` | Its size; 1 is native | | `label` | A nameplate over its head, seen from up to 25 m | | `promptText` | A second line, seen only within `promptRange`, usually inviting players to interact | @@ -85,5 +90,6 @@ The prompt is only text. Handling the interaction it invites, such as opening a ## Where to go next - [Catching beasts](/guides/npcs-and-creatures/catching-beasts/) for the Nab-Sack, the player's bag and releasing beasts. +- [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/) for walking beasts and characters, and beasts that follow players. - [NPCs](/guides/npcs-and-creatures/npcs/) for enemies and their AI, and [Rewards and loot](/guides/pve/rewards-and-loot/) for what their deaths pay. - [Authority](/guides/core-concepts/authority/) for which client runs a creature's AI and what that means. diff --git a/guides/NPCs and creatures/movement.md b/guides/NPCs and creatures/movement.md new file mode 100644 index 0000000..1f867c8 --- /dev/null +++ b/guides/NPCs and creatures/movement.md @@ -0,0 +1,229 @@ +--- +title: Moving NPCs and creatures +description: Walk NPCs, beasts and characters along paths of waypoints, have a beast follow someone, choose what each does when it has nowhere to go, and hear arrivals through events. +sidebar: + order: 410 +--- + +The server can walk the NPCs, beasts and characters it spawns: along a path of waypoints, to a single point, or, for a beast, after someone. A guard can patrol a wall, a house-elf can pace the kitchens and a hippogriff can follow the player who looks after it. The three kinds share one path format and the same calls, but each walks its own way. + +## How each kind walks + +| | NPC | Beast | Character | +| --- | --- | --- | --- | +| What walks it | Its owner's game, with the game's own pathfinding | Its owner's game, with the game's own creature AI | The server, which every player's game follows | +| Between points | Finds its way round obstacles | Finds its way round obstacles, or flies | A straight line | +| With no player near | The path holds until a player comes near | The path holds until a player comes near | It keeps walking | +| Arrivals reported by | The owner's game | The owner's game | The server | + +An NPC's or beast's owner is the nearby player whose game runs its AI (see [Who runs the AI](/guides/npcs-and-creatures/npcs/#who-runs-the-ai)). Because that game reports the arrivals, treat an NPC's or beast's path events like anything else a client reports. + +## Paths + +`moveAlong(waypoints, options)` walks the entity through 1 to 64 waypoints in order, and `moveTo(x, y, z, options)` walks it to one point. Either replaces the path it was on. + +A waypoint is a player's position, as `player.position` reads it, about 90 cm above the floor, so a spot a player stood on can be used as it is: + +```js +// server/main.js +const patrol = []; // points marked with /mark + +Events.on("chatCommand", (player, message, command) => { + if (command === "mark") { + const { x, y, z } = player.position; + patrol.push({ x, y, z, wait: 4 }); + player.sendChat(`Patrol point ${patrol.length} marked.`); + } else if (command === "patrol" && patrol.length > 0) { + const { x, y, z } = patrol[0]; + const guard = NPC.create("Goblin_Grunt", x, y, z, "friendly"); + guard?.moveAlong(patrol, { loop: "pingpong" }); + } +}); +``` + +| Waypoint field | Meaning | +| --- | --- | +| `x`, `y`, `z` | Where to go, in world centimetres | +| `wait` | Seconds to stand there on arrival, 0 to 3600 | +| `gait` | `"walk"`, `"jog"` or `"sprint"`: the pace of the leg that arrives there | + +| Option | Meaning | +| --- | --- | +| `gait` | The pace of every leg whose waypoint names none: `"walk"` by default, or a beast's own `gait` | +| `loop` | `"once"`, the default, ends at the last point; `"loop"` walks from the last point back to the first and round again; `"pingpong"` walks back and forth along the points | + +A path with one point always runs once, and a `"loop"` or `"pingpong"` path runs until you stop or replace it. A malformed path throws: no points or more than 64, a missing coordinate, an unknown gait or a wait out of range. `moveAlong` and `moveTo` return `false` when the entity is gone, or the NPC is dead. Each kind adds options of its own, in its section below. + +`pause()` holds a path where it is, and `resume()` carries on from there. `stop()` ends the path and leaves the entity where it is. + +### Reading a path + +`entity.movement` reads where its path stands: + +| Field | Meaning | +| --- | --- | +| `mode` | `"path"` while a path runs, waits and pauses included; `"follow"` while a beast follows; `"idle"` otherwise | +| `waypoint` | The index of the point it is walking to or waiting at | +| `waypoints` | How many points the path has; 0 when idle | +| `lap` | How many laps a `"loop"` or `"pingpong"` path has completed | +| `paused` | Whether `pause()`, or an NPC's or beast's `aiPaused`, holds it | + +`entity.waypoints` returns the running path's points as you gave them, each with the gait it walks at filled in (a character's with its `speed`), or an empty array. + +## Path events + +Each kind raises its own pair of events: + +| Event | Handler arguments | When it fires | +| --- | --- | --- | +| `npcWaypoint`, `beastWaypoint`, `characterWaypoint` | `(entity, index, lap)` | It reaches `waypoints[index]`, before the wait there | +| `npcMoveEnded`, `beastMoveEnded`, `characterMoveEnded` | `(entity, reason)` | Its path ends | + +`lap` counts completed laps, so on a `"loop"` path the arrival back at point 0 reports lap 1. `reason` says why the path ended: + +| Reason | Meaning | +| --- | --- | +| `"arrived"` | A `"once"` path reached its last point and finished the wait there | +| `"stopped"` | `stop()` ended it, a script set a beast's or character's `position`, or the NPC died | +| `"replaced"` | A new path, or a beast's new command, took over. It fires before the new path's events | +| `"unreachable"` | NPCs and beasts: the game found no way to a point | +| `"targetGone"` | Beasts: the entity it was following left | + +Destroying an entity ends its path without an event. + +```js +// server/main.js +Events.on("beastWaypoint", (beast, index, lap) => { + if (index === 0 && lap === 3) beast.stop("relax"); // three laps, then a rest +}); + +Events.on("npcMoveEnded", (npc, reason) => { + if (reason === "unreachable") npc.destroy(); +}); +``` + +## What other players see + +A character's path travels with it: its points, when it started and whether it is paused. A player who comes into range mid-path sees the character where everyone else does, partway along a leg, because every player's game plays the walk on the server's clock. On the server, `character.position` follows the walk, and a client script reads where a character is on its own player's screen with `Character.pose(id)`. + +An NPC's or beast's path is held on the server, and its owner's game walks it; everyone else sees it move as they see any NPC. When the owner changes, the new owner's game carries on from the next waypoint, or finishes the wait it was in, so no arrival is reported twice. + +## NPCs + +An NPC walks with the game's own pathfinding and its family's own gaits: walk is its calm wander, jog its combat move, and sprint that move at 1.3 times the speed. Its options add: + +| Option | Meaning | +| --- | --- | +| `radius` | How near a point counts as reached, 0 to 5000 cm; the game's own, about 50, by default | +| `speedScale` | The game's own speed scale for every leg, 0.25 to 3; 1 by default. Jog and sprint follow it, and the calm walk keeps its stride | +| `then` | The idle a `"once"` path ends in: `"stand"` by default. See [Idling](#idling) | + +A fight takes over. An NPC that gets into one holds its path and carries on once it is calm again. `pause()`, `aiPaused` and `movementFrozen` hold the path too, and stop the clock of a wait (see [Switching behaviour off](/guides/npcs-and-creatures/npcs/#switching-behaviour-off)). + +### Idling + +`npc.idle` decides what an NPC does while no path runs: + +| Idle | What it does | +| --- | --- | +| `"native"` | The game's own: it wanders where it spawned. The default | +| `"stand"` | Holds at its home, and walks back whenever it ends up more than 3 m away, such as after a fight | +| `"wander"` | Roams around its home | + +`npc.home` is that home, a player's position, or `null` until one is set. The first `"stand"` or `"wander"` makes the spot the NPC stands on its home, and you can set it yourself. A `"once"` path that arrives moves the home to its last point and takes up the path's `then` idle, so by default an NPC stands guard where its path ends: + +```js +// server/main.js +Events.on("chatCommand", (player, message, command) => { + if (command !== "guard") return; + const { x, y, z } = player.position; + const guard = NPC.create("Goblin_Grunt", x + 1500, y, z, "friendly"); + guard?.moveTo(x, y, z, { gait: "jog" }); // then stands guard here +}); +``` + +## Beasts + +A beast walks through the game's own creature AI, at its species' own walk, jog and sprint. Ground moves need the game's navmesh, which covers Overland, Hogwarts and the vivariums. + +| Property | Meaning | +| --- | --- | +| `gait` | The gait `moveAlong`, `moveTo` and `follow` use when they name none: `"walk"` by default | +| `idle` | What it does with no command: `"relax"`, the default, stays near where it was put, sitting and lying; `"wander"` explores, grazes and rests around its area; `"stand"` stands | +| `area` | A sphere it idles inside, as `{ x, y, z, radius }` with the radius in centimetres, or `null`. The game picks its idle spots inside it, and it walks back if it ends up outside. Paths and follows are not limited by it | + +A beast's path options add `then`, the idle a `"once"` path ends in, which becomes `beast.idle`: `"stand"` by default. `beast.stop(then)` takes one too, also `"stand"` by default, so a stopped beast stands unless you name another idle. + +`pause()` holds an idle beast still too. `aiPaused` holds its whole AI, across new commands, until you set it back to `false`. Setting `beast.position` teleports it and ends its path or follow as `"stopped"`. + +### Following + +`beast.follow(target, options)` walks after a player, beast, NPC or character and keeps about 1.5 m off, until `stop()` or a new command. It throws for anything else, and for the beast itself. While it follows a player in its own virtual world, that player's game runs it. `movement.targetId` is the id of the entity it follows. + +| Option | Meaning | +| --- | --- | +| `gait` | As on a path | +| `teleportBeyond` | Metres behind the target past which the server puts the beast beside it: 60 by default, 0 never | +| `air` | Fliers only: fly after the target while it is airborne or more than 30 m away, about 4 m behind and 3 m above it, and land beside it once it is back on the ground nearby | + +```js +// server/main.js +const pets = new Map(); // player id -> their beast + +Events.on("chatCommand", (player, message, command) => { + const pet = pets.get(player.id); + if (!pet) return; + if (command === "heel") { + const canFly = Beast.catalog().find((species) => species.id === pet.species)?.canFly ?? false; + pet.follow(player, { gait: "jog", air: canFly }); + } else if (command === "stay") { + pet.stop("relax"); + } +}); +``` + +### Flying + +Fliers can reach points in the air. `Beast.catalog()` marks them `canFly`: the adult Fwooper, Hippogriff, Jobberknoll, Phoenix and Thestral. Add `air: true` to a waypoint, or to `moveTo`'s options, to fly there; a ground point after air points lands there. `air` throws for a beast that cannot fly. Flight needs the game's flight volumes, which the castle's interior lacks. `movement.flying` reads whether a beast is in the air now. + +## Characters + +A character has no AI, so the server plays its path. It walks from where it stands to each point in a straight line at a steady speed, so lay points out where nothing is in the way. Its path runs whether anyone is near or not, and each body walks on its own animations, a ghost or a centaur as much as a townsperson. + +Its gaits are fixed speeds: walk is 150 cm/s, jog 475 and sprint 700. Waypoints and options can name a speed instead: + +| Waypoint field | Meaning | +| --- | --- | +| `speed` | The speed of the leg that arrives there, 20 to 1000 cm/s, instead of a gait | +| `yaw` | The heading to turn to while it waits there, in degrees; by default it keeps facing the way it walked | + +| Option | Meaning | +| --- | --- | +| `speed` | The speed of every leg that names none, 20 to 1000 cm/s, instead of `gait` | +| `ground` | `"snap"`, the default, stands the body on the floor under it in each player's game, and uses the path's height where there is none; `"path"` follows the heights you gave | +| `turnRate` | How fast it turns at corners and waits, 1 to 3600 degrees a second: 270 by default | + +A new path starts from wherever the character is, even partway along a leg. `pause()` stops the walk's clock and its events until `resume()`. Setting `character.position` moves it there on every player's screen and ends its path as `"stopped"`. + +`Character.isMovable(characterId)` tells whether a body can walk. The Sorting Hat, the Howler and the busts cannot, and `moveAlong` and `moveTo` throw for them. + +```js +// server/main.js +const crier = Character.create("town-crier", "T3TownspersonMale1", { x, y, z, label: "Town crier" }); + +// Up the street and back, turning round to face the way it came at each end. +crier?.moveAlong([ + { x: x + 1200, y, z, wait: 30, yaw: 180 }, + { x, y, z, wait: 30, yaw: 0 }, +], { loop: "loop" }); + +Events.on("characterWaypoint", (character) => { + if (character.id === crier?.id) announceNear(character.position, "Hear ye! The market opens at noon."); +}); +``` + +## Where to go next + +- [NPCs](/guides/npcs-and-creatures/npcs/) for who runs an NPC's AI and switching its behaviour off. +- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/) for spawning beasts and the bodies a character can have. +- [Authority](/guides/core-concepts/authority/#what-clients-report) for what an owner's game reports and how far to trust it. diff --git a/guides/NPCs and creatures/npcs.md b/guides/NPCs and creatures/npcs.md index 81dcce7..832db71 100644 --- a/guides/NPCs and creatures/npcs.md +++ b/guides/NPCs and creatures/npcs.md @@ -23,11 +23,11 @@ if (grunt) { - `enemyId`: one of the game's enemy ids, one of the Mod's short names such as `Goblin_Grunt`, or an enemy's class path. The server does not check it for you: a misspelled id returns an NPC that never appears. - A position in world centimetres. - `disposition`: `"hostile"` (the default) or `"friendly"`. -- `preferredOwnerId`: the player whose client should run its AI. It is used only if that player can see world 0 when the NPC spawns; otherwise the server picks. +- `preferredOwnerId`: the player whose client should run its AI. The NPC starts in that player's virtual world and map. It returns the NPC, or `undefined` if it could not be created. `setScale` takes effect best right after `create`, before the NPC first reaches anyone. Pass `"visual"` as its second argument to resize only the body and keep native collision. -NPCs start in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0. The NPCs a resource creates are destroyed when that resource stops; [camp](/guides/encounters/camps/) and [world event](/guides/encounters/world-events/) enemies, and enemies an NPC summons, belong to no resource. +Without a `preferredOwnerId`, NPCs start in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0. The NPCs a resource creates are destroyed when that resource stops; [camp](/guides/encounters/camps/) and [world event](/guides/encounters/world-events/) enemies, and enemies an NPC summons, belong to no resource. ## Who runs the AI @@ -56,6 +56,8 @@ An NPC never gets its health back by itself, and its owner finishes off any NPC `launch` returns `false` for a dormant NPC; `true` means it was sent. Pass `{ overrideXY: true }` or `{ overrideZ: true }` to replace the NPC's own speed instead of adding to it, and `{ space: "local" }` to launch along its own facing. +To walk an NPC to a spot, send it on a patrol or choose what it does when idle, see [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/#npcs). + ## Switching behaviour off Five properties switch parts of an NPC's behaviour off. They are held on the server, can be set before the NPC has an owner, and carry over hand-offs: @@ -88,4 +90,4 @@ function trainingDummy(x, y, z) { ## Where to go next - [Fighting NPCs](/guides/pve/fighting-npcs/) for what happens when several players fight one. -- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/) for tame creatures and stationary townspeople. +- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/) for tame creatures, townspeople and ghosts. diff --git a/guides/Players/appearance.md b/guides/Players/appearance.md index 6a50762..5d93921 100644 --- a/guides/Players/appearance.md +++ b/guides/Players/appearance.md @@ -1,11 +1,11 @@ --- title: Appearance -description: Save and restore how players look, open the character creator, and change their clothes, house, size, disguise and what they hold. +description: Save and restore how players look, open the character creator, change their clothes, house, size, disguise, animal form and what they hold, and choose whether players collide. sidebar: order: 330 --- -A player's look is their body, face, hair, voice and name. Their own game builds it and sends it to the server, which checks it and shows it to everyone else. The server can read a look, keep it and hand it back, and it controls a player's house, size, disguise and the prop in their hand. Clothes are gear in the player's [inventory](/guides/players/inventory/). +A player's look is their body, face, hair, voice and name. Their own game builds it and sends it to the server, which checks it and shows it to everyone else. The server can read a look, keep it and hand it back, and it controls a player's house, size, disguise, animal form and the prop in their hand. Clothes are gear in the player's [inventory](/guides/players/inventory/). ## The look @@ -18,6 +18,8 @@ A player's look is their body, face, hair, voice and name. Their own game builds | `presets` | The creator's choice for each category, such as `{ hairStyle: "HairFemale040" }` | | `gear` | What the player wears, as the game's appearance ids | | `faceGear`, `house`, `scale`, `voice`, `name`, `hood`, `wandStyle` | Present when set: glasses, house colours, size, voice, character name, hood and wand style | +| `facialHair` | The beard or moustache, such as `"M_Facial_zBase_FullBeard"`. Absent or `""` is clean-shaven. | +| `facialHairColor` | `"hair"` to match the hair, or a `"#rrggbb"` colour. Absent, the facial hair keeps its own colour. | `playerAppearanceChanged` fires with the player, their new look and its revision whenever the look changes, and `player.appearanceRevision` counts those changes for the current visit. A player's game sends at most two looks a second. @@ -58,7 +60,7 @@ Events.on("playerDisconnect", (player) => restored.delete(player.id)); `player.setLook(look, callback)` takes a look or its JSON text. The callback runs with `(error, result)` once the player's game has rebuilt their character, within 20 seconds, and `result.revision` is the revision it produced. Without a callback, a malformed look throws `INVALID_APPEARANCE`. The server checks a look's shape, not that its ids exist in the game, and the player's game leaves out ids it does not know, so read the look back if it matters. -A look's `gear`, `hood` and `house` show how other players see the player, but do not dress the player themselves. What they wear, their hood and the look a worn item shows all come from their [inventory](/guides/players/inventory/#saving-inventories), so restoring it restores them. Restore the house with [`player.house`](#house-and-gender). +A look's `gear`, `hood` and `house` show how other players see the player, but do not dress the player themselves. What they wear, their hood and the look a worn item shows all come from their [inventory](/guides/players/inventory/#saving-inventories), so restoring it restores them. When a player raises or lowers their hood, their look changes with it and other players see it. Restore the house with [`player.house`](#house-and-gender). ## The character creator @@ -74,6 +76,8 @@ A disguised player's game stops sending their look, so take off a [disguise](#di A resource can build a creator of its own. `Creator.begin()` starts an editing session, `Creator.getSchema()` lists every category and the ids it accepts, `Creator.applyPatch({ hairStyle: id })` changes categories, and `Creator.commit()` keeps the changes or `Creator.cancel()` puts the old look back. `CreatorPreview.setCamera(frame)` points a camera at the front of the player and `CreatorPreview.rotate(degrees)` turns them, until `CreatorPreview.restoreCamera()`. `Creator.exportLook()` returns the player's look on the client. +Facial hair is two categories, `facialHair` and `facialHairColor`, which take the look's own values. The schema lists the game's beards and moustaches and a few natural shades, `applyPatch` also takes any `"#rrggbb"` colour, and an empty id takes the facial hair off or gives it back its own colour. Offer them only when `Creator.getCapabilities().facialHair` is `true`, as older builds do not have them. + `Portrait.capture(request)` renders a picture of a player, or of a saved look, for a [web view](/guides/user-interface/web-views/). One capture runs at a time and takes a second or so: check `Portrait.busy()`, then `Portrait.lastImage()` returns it as a data URI. ## Clothes @@ -99,7 +103,7 @@ function wearUniform(player) { ## Size -`player.setScale(scale, mode)` resizes a player for everyone, from 0.1 to 10 times. In the default `"full"` mode their collision and the target others lock on to grow with them; `"visual"` resizes only the body and what they wear. `player.getScale()` and `player.getScaleMode()` read it back. +`player.setScale(scale, mode)` resizes a player for everyone, from 0.1 to 10 times. In the default `"full"` mode their collision and the target others lock on to grow with them, and their feet stay on the ground as they do; `"visual"` resizes only the body and what they wear. `player.getScale()` and `player.getScaleMode()` read it back. ```js // server/main.js @@ -117,6 +121,41 @@ A look saved while a player is resized carries the size, and restoring it resize A disguise is not part of the look: `getLook` never includes it, and `setLook` takes it off. +## Animal forms + +`player.setAnimalForm(form)` turns a player into one of the game's beasts, on every client including their own. Their human body is hidden and the beast stands in its place, walking, trotting or galloping to match how fast the player moves. `player.setAnimalForm(null)` turns them back, and `player.getAnimalForm()` reads the form, or `null`. + +| Option | Meaning | +| --- | --- | +| `species` | Any species in [`Beast.catalog()`](/guides/npcs-and-creatures/beasts-and-characters/#beasts), such as `"Niffler"`, `"Hippogriff"` or `"KneazleOffspring"` | +| `look` | `"species"`, the default, or `"cat"`, which dresses a Kneazle as the castle's grey cat | +| `size` | The animal against its natural size: 1 by default, 2 for twice as big | + +```js +// server/main.js +Events.on("chatCommand", (player, message, command, args) => { + if (command !== "cat") return; + if (player.getAnimalForm()) { + player.setAnimalForm(null); + return; + } + const size = args.length > 0 ? Number(args[0]) : 1; + if (!(size > 0)) { + player.sendChat("Usage: /cat [size], such as /cat 2 for a cat twice as big."); + return; + } + player.setAnimalForm({ species: "Kneazle", look: "cat", size }); +}); +``` + +`setAnimalForm` throws a `RangeError` for a species the catalog does not list, for the cat look on anything but a Kneazle, and for a size of 0 or less. + +The player is still themselves under the beast. They move at a human's pace, and their collision, camera, hitbox and nametag are fitted to the animal. Their own game does not let them cast spells, and other players see no wand or held prop. A species that flies in the game, one with `canFly` in the catalog, takes its flight poses whenever the player is more than 1.2 m above the ground, but the form does not lift them there. + +`getAnimalForm()` returns the `species`, the `look`, the `size` worn and the `scale`: how large the player is against a human. That scale is held to `setScale`'s range of 0.1 to 10. A size that would take it further is cut back to fit, and `size` reads back the size worn. [`setScale`](#size) and `getScale` keep working on the human meanwhile: a size set during the form is the one the player comes back at. + +The form swaps the body at once, with no animation of its own. An animagus is yours to build around it: play the game's own human-to-cat clips with `LocalPlayer.playClip` and its morph effects before the swap, set `setDamageGivenMultiplier(0)` while a player is an animal if animals should not fight, leave a player who is down or riding as they are, and change players back when they die. + ## Holding props `player.equipProp(path, options)` puts one of the game's meshes in a player's hand, and everyone sees it. `options.socket` picks the attachment point, `WandSocket` by default, and `options.offset` adjusts its position (in centimetres), rotation and scale. `player.getProp()` reads it, and `player.clearProp()` takes it away. @@ -136,11 +175,29 @@ A prop handed to a player before they have a body is dropped, so wait for `loadi On the client, `LocalPlayer.setVisibility(false)` hides the player's own body, wand and held prop on their screen only, for a first-person camera or a cutscene. Everyone else still sees them, unless the server hides them with `player.setVisible(false)`. It lasts through respawns and travel until you change it. +## Player collision + +Players walk through each other by default. `World.setPlayerCollision(true)` makes them block each other's movement on every client, and `World.playerCollision` reads the setting back. + +```js +// server/main.js +World.setPlayerCollision(true); + +Events.on("loadingFinished", (player) => { + if (isStaff(player)) player.setCollidable(false); // staff walk through crowds +}); +``` + +It only stops players walking into each other. NPCs, beasts, spells and the world are untouched, and nobody can push anyone. Two players who end up inside each other, after a teleport or at a shared spawn point, pass through until they have moved apart. Players who are hidden with `setVisible(false)`, riding a broom or creature, dead, disillusioned or in an [animal form](#animal-forms) never block. + +`player.setCollidable(false)` takes one player out of it: they pass through everyone, and everyone through them. `player.isCollidable()` reads it, `true` by default. Both settings belong to the server, and a player's own client cannot undo them. + ## What a visit keeps -House, size, disguise and props last for the visit and are gone when the player reconnects, like the look itself. Save what should last and set it again on `loadingFinished`. +House, size, disguise, animal form, props and whether a player collides last for the visit and are gone when the player reconnects, like the look itself. Save what should last and set it again on `loadingFinished`. ## Where to go next - [Inventory](/guides/players/inventory/) for the gear players wear. - [Join and spawn](/guides/players/join-and-spawn/) for the events around a visit. +- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/#beasts) for the species an animal form can take. diff --git a/guides/Players/custom-spells.md b/guides/Players/custom-spells.md new file mode 100644 index 0000000..50293fc --- /dev/null +++ b/guides/Players/custom-spells.md @@ -0,0 +1,319 @@ +--- +title: Custom spells +description: Define spells of your own on top of the game's, give them to players, react to their casts, price them and modded spells in PvP, and add spell lessons of your own. +sidebar: + order: 345 +--- + +A server can add spells to the game. A defined spell runs one of the game's player spells for its look and behaviour, with its own name, icon, numbers and lock, and can take parts from other spells: a Confringo that freezes what it hits, or a silent Depulso whose cast summons a Patronus. No mod is needed. Every player's game adds the spell to its own spell table, then casts it, cools it down and shows it to other players like any other spell. + +On the server, `Spells` defines spells. On a client, `Spells` is the [spell policy and spell bar](/guides/players/progression/#spells), and `SpellLearning` runs spell lessons, including [lessons of your own](#lessons-of-your-own). + +## Defining a spell + +```jsx +// server/main.js +Spells.define({ + id: "Firestorm", + base: "Confringo", + name: "Firestorm", + description: "A searing bolt that sets what it hits ablaze.", + icon: "my-spells/img/firestorm.png", + incantation: "Incendio", + stats: { damage: 40, cooldown: 6, range: 2500, projectileSpeed: 6000 }, +}); +``` + +`Spells.define(definition)` adds the spell to every player's game, now and for everyone who joins later. Defining the same id again replaces that spell for everyone, live. It throws for anything it can't use, and returns the definition as the server keeps it. + +| Field | Meaning | +| --- | --- | +| `id` | The spell's id: 3 to 32 characters, a letter, then letters, digits or `_`. It can't be one of the game's spells | +| `base` | The game's player spell it runs: its projectile, effects and cast | +| `name` | The title the spell menu and spell bar show, at most 48 bytes. The id by default | +| `description` | The spell menu's text about it, at most 300 bytes | +| `icon` | A PNG for its symbol; see [Icons](#icons). The base's symbol by default | +| `incantation` | Another player spell whose line the caster says, or `"none"` for a silent cast. The base's line by default | +| `stats` | Numbers that replace the base's; see [Stats](#stats) | +| `parts` | Parts taken from other player spells; see [Parts](#parts) | + +`name` and `description` can't hold `<`, `>`, `{` or `}`. The game finds a spell's title by its id, so an id the game already uses for some text of its own shows that text instead of `name`. If your name doesn't show, pick a more distinctive id. + +`base`, `incantation` and each part name a player spell the way the game's spell records do, ignoring case. For most spells that is the lock in the [spell table](/guides/players/progression/#spells) without `Spell_`: `"Confringo"`, `"Glacius"`, or `"Expulso"` for Bombarda. Pick a base that goes on the spell bar. A spell with a key of its own, such as Revelio, Lumos or Protego, or with none, such as Episkey, can't be slotted. + +### Stats + +| Stat | Meaning | Range | +| --- | --- | --- | +| `damage` | The damage a hit deals; per second for a spell that burns, such as Crucio | 0 to 100,000 | +| `objectDamage` | The damage a hit deals to objects | 0 to 100,000 | +| `cooldown` | Seconds before it can be cast again | 0 to 3,600 | +| `range` | How far it reaches, in centimetres | 0 to 50,000 | +| `projectileSpeed` | How fast its projectile flies | 0 to 100,000 | +| `projectileGravity` | The gravity on its projectile | -100,000 to 100,000 | +| `duration` | How long its effect holds a character it hits, in seconds | 0 to 3,600 | +| `playerDuration` | How long its effect holds a player it hits, in seconds | 0 to 3,600 | +| `chargeTime` | Its charge time | 0 to 60 | +| `impulse` | How hard it pushes what it hits | 0 to 1,000,000 | + +A stat left out, or `null`, keeps the base's value, and a value out of range is clamped. An unknown stat throws. Like every spell's, a defined spell's `cooldown` only runs in full in a fight: outside one, the game recharges spells far faster. See [Spell cooldowns](/guides/pvp/hits-and-defeat/#spell-cooldowns). + +### Parts + +`parts` takes pieces of other player spells, each named like `base`. A part left out stays the base's. + +| Part | What it takes | +| --- | --- | +| `projectile` | What the spell fires and how that lands | +| `onHit` | The effect it leaves on what it hits, such as a burn or a freeze, and the effects that effect ends | +| `cast` | The cast's animation and timing | +| `muzzle` | The effects and sounds at the wand tip as it charges and fires | + +```jsx +// server/main.js +// Confringo's fireball, carrying Glacius's freeze to whatever it hits. +Spells.define({ + id: "Frostfire", + base: "Confringo", + name: "Frostfire", + description: "A fireball that freezes whatever it strikes.", + parts: { onHit: "Glacius" }, + incantation: "Glacius", + stats: { cooldown: 8 }, +}); +``` + +With `parts: { projectile: "Glacius", onHit: "Glacius" }`, the same Confringo fires an ice bolt that freezes. + +### Icons + +`icon` is a PNG in one of your resources, written `"/.png"`, such as `"my-spells/img/firestorm.png"`. The spell bar and the spell menus show it in place of the base's symbol. Each player's game reads it from the resource it downloaded, so: + +- the file must be in the server's `resources/` directory when you define the spell, or `define` throws; +- the resource must list it in its manifest's `files`; +- the resource needs a client script, because a resource without one never [reaches players](/guides/getting-started/first-resource/#what-reaches-the-player). + +### Reading and removing spells + +`Spells.get(id)` returns one spell, matching the id whatever its case, or `null`. `Spells.list()` returns every one. A definition reads back as the server keeps it: the spells it names are spelled the game's way, `name` is filled in, and `icon` and `incantation` are `null` where they are the base's. Its `lock` is `Spell_` followed by its id. + +The server holds up to 32 defined spells, across every resource. They belong to the server, not to the resource that defined them, so they stay when it stops. Defining the same spell again when it starts back changes nothing. + +`Spells.remove(id)` removes a spell for players who join later. Players already connected keep it until they reconnect, so close its lock for them as well. It returns `false` for an id the server doesn't hold. + +## Giving players the spell + +A defined spell starts locked for every player. Open its lock through the [spell policy](/guides/players/progression/#spells), the way you open any spell, and it joins the player's spell menu. Players then put it on the spell bar from that menu, and `Spells.setLoadoutSlot(slot, id)` slots it from a client script. Closing the lock again takes it away and clears it from the spell bar. + +```jsx +// server/main.js +const firestorm = Spells.define({ id: "Firestorm", base: "Confringo", name: "Firestorm", stats: { damage: 40, cooldown: 6 } }); + +Events.on("worldReady", (player) => { + player.emit("spells:policy", JSON.stringify({ unlocked: ["Spell_Lumos", "Spell_Stupefy", firestorm.lock], spellSets: 1, revoked: [] })); +}); +``` + +The client half is the `spells:policy` handler in [Progression](/guides/players/progression/#spells). The game only shows the spell bar once Lumos is open, so a server that hands out spells opens `Spell_Lumos` too. + +To make players earn a spell, run a lesson for it and open the lock when they finish; see [Lessons of your own](#lessons-of-your-own). + +## Casting + +A player casts a defined spell like any other, and every player's game shows it with its own parts and numbers. On the caster's client, `spellCast` names it by its id rather than a spell record. + +On the server, `playerSpellCast(player, spell, cast)` fires when a player casts a defined spell. `spell` is its id, and `cast` says where it was aimed: + +| Field | Meaning | +| --- | --- | +| `aimYaw` | The world yaw the cast was aimed along, in degrees: where the caster's camera pointed, or the caster's facing when their game sent none | +| `wand` | The caster's wand tip as the cast began, as `{ x, y, z }`. Their position when their game sent none, or one more than 5 m from them | + +What a cast does to what it hits comes from its base and parts. Anything more is a script: heal on a cast, summon something, or change its PvP hits in the [policy](/guides/pvp/hits-and-defeat/#the-policy-hook). + +```jsx +// server/main.js +// Mend runs Arresto Momentum, which only slows what it hits. The heal is ours. +Spells.define({ id: "Mend", base: "ArrestoMomentum", name: "Mend", description: "Restores some of the caster's health.", incantation: "none", stats: { cooldown: 12 } }); + +Events.on("playerSpellCast", (player, spell) => { + if (spell !== "Mend") return; + const vitals = /** @type {{ hp: number; max: number } | null} */ (Pvp.getVitals(player.id)); + if (vitals) player.setHealth(Math.min(vitals.max, vitals.hp + 40)); +}); +``` + +The caster's game reports each cast, at most 4 a second per player, and the server drops the rest. A modified game can report casts it never made, so use the event for effects like these, not for anything a cheater must not be able to trigger. See [Authority](/guides/core-concepts/authority/). + +## Spells in PvP + +The server [prices every PvP hit](/guides/pvp/hits-and-defeat/#damage) itself. Defined spells and spells that mods add are outside its damage table, so each has its own rule. + +### Defined spells + +A hit by a defined spell is named by its id: that is the `spell` the [policy](/guides/pvp/hits-and-defeat/#the-policy-hook) and `pvpHit` see, and the policy's `spellId` is 0. The server prices it from the definition, never from what the caster's game says: + +1. `Pvp.setSpellDamage(id, damage)`, when you have set one, with the id spelled as you defined it; +2. otherwise its `damage` stat. For a base that burns, such as Crucio, `damage` is per second, and `duration` is how many seconds it burns, up to 60; +3. otherwise its base's price. + +The price then scales with `damageScale` and the players' damage multipliers, like any spell's. A `damage` of 1,000,000 or more kills. A spell whose base is the Killing, Cruciatus or Imperius Curse counts as that curse, so `curses: "off"` keeps it away from players. + +### Modded spells + +A modded spell is one outside the game's own list of player spells: a spell a CurseForge mod adds, or an enemy's spell a mod hands to players. The caster's game reports its hit by the spell's record path, priced from its own copy of the spell's data. The server can't check that, so it caps the price at the `moddedSpellCap` rule: 100 by default, per hit, before `damageScale`. For a spell that burns, the cap covers the whole burn. + +`Pvp.setSpellDamage` with the spell's record path or name sets its price exactly, past the cap. The server log names each modded spell the first time one of its hits lands, with that call. + +```js +// server/main.js +Pvp.configure({ moddedSpellCap: 40 }); +Pvp.setSpellDamage("DA_Spell_CrucioVehemSpellRecord", 60); +``` + +A modded spell whose own damage kills stays lethal, whatever the cap. Under `curses: "off"`, the server drops a modded spell's hit when its damage kills or its record's name holds `AvadaKedavra`, `Crucio` or `Imperius`. In the policy, a modded spell has `spellId` 0 and its record path as `spell`. + +The cap never applies to a defined spell: the server knows its numbers. + +## Summoning a Patronus + +A spell can summon something the game's spells never do. This one runs Depulso, silently and without damage, and the Patronus is not part of the spell: on `playerSpellCast`, the script creates a [world object](/guides/world/objects-and-effects/#world-objects) from one of the game's creatures, gives it the glow of the game's own Patronus, and sends it galloping off along the cast's `aimYaw` for three seconds. A shockwave plays at the wand tip. What the cast hits still gets Depulso's knock-back. + +```jsx +// server/main.js +const asset = (path) => `/Game/${path}.${path.split("/").pop()}`; + +const STAG = asset("RiggedObjects/Characters/Creatures/Stag/SK_DeerStag"); +const GALLOP = asset("Animation/Creatures/Deer/Deer_BM_Gallop_anm"); +const GLOW = asset("VFX/MasterMaterials/MaterialInstance/VFX_MI_CIN_Glow_Patronus"); +const TRAIL = asset("VFX/Particles/Magic/Patronus/VFX_NS_CIN_PatronusRabbit_Trail"); +const PULSE = asset("VFX/Particles/Magic/Patronus/VFX_NS_Patronus_Shockwave"); +const PUFF = asset("VFX/UI/Patronus/VFX_NS_Patronus_Spawn"); + +Spells.define({ + id: "PatronumStag", + base: "Depulso", + name: "Expecto Patronum (Stag)", + description: "Summons a stag Patronus that charges ahead of you.", + icon: "my-spells/img/patronus_stag.png", + incantation: "none", + stats: { cooldown: 30, damage: 0 }, +}); + +let summons = 0; +Events.on("playerSpellCast", (player, spell, cast) => { + if (spell !== "PatronumStag") return; + const key = `my-spells:patronus:${++summons}`; + const heading = (cast.aimYaw * Math.PI) / 180; + const at = player.position; + + Fx.create(`${key}:pulse`, PULSE, { x: cast.wand.x, y: cast.wand.y, z: cast.wand.z, ttl: 2 }); + WorldObject.create(key, STAG, { + x: at.x + Math.cos(heading) * 120, + y: at.y + Math.sin(heading) * 120, + z: at.z, + yaw: cast.aimYaw - 90, // the game's creature meshes face +Y + collision: false, + animation: GALLOP, + material: GLOW, + fx: TRAIL, + fxSocket: "Pelvis", + motion: { speed: 900, heading: cast.aimYaw, followGround: true }, + ttl: 3.2, + shrinkOut: 0.4, + startFx: PUFF, + endFx: PUFF, + }); +}); +``` + +Its icon is a PNG under the resource's `img/`, listed in its manifest's `files`, and the resource has a client script so the icon reaches players. + +## Lessons of your own + +The game teaches each spell with a lesson: the player traces the spell's shape while a spark runs along it, and presses the button each checkpoint shows as the spark reaches it. `SpellLearning.start` plays the game's own lessons; see [Spell lessons](/guides/players/progression/#spell-lessons). A client script can also add lessons of its own with `SpellLearning.define(id, lesson)`, and `SpellLearning.start(id)` runs one like a stock lesson. Its `spellLearningFinished` result carries your id as `spell`. + +A lesson starts from one of the game's, named by `look`, and changes only what you set. Or it brings a `path` of its own, a list of points on a 1024 × 1024 canvas, with x to the right and y down. Its art is then drawn from the points, so it needs no images: + +```js +// client/main.js +SpellLearning.define("LumosHard", { + title: "Lumos (advanced)", + look: "Lumos", + chaser: 0.6, + checkpoints: [{ at: 0.2, button: 2 }, { at: 0.45, button: 3 }, { at: 0.7 }, { at: 0.9, button: 4 }], +}); + +const { ok, reason } = SpellLearning.define("Fulgari", { + title: "Fulgari", + icon: "Confringo", + path: [[200, 850], [500, 200], [520, 820], [820, 180]], +}); +if (!ok) console.log(`The Fulgari lesson was refused: ${reason}`); +``` + +| Field | Meaning | +| --- | --- | +| `title` | The lesson's title, up to 128 characters. Required | +| `look` | One of `SpellLearning.supportedSpells()` to start from: its path, art, checkpoints, chaser and icon | +| `path` | 3 to 65 `[x, y]` points, 800 to 4000 px long in all. Replaces the look's path | +| `checkpoints` | Up to 16 button prompts along the path; see below | +| `finalButton` | The button, 1 to 4, shown at the end of the path. 1 by default | +| `chaser` | When the bad spark starts chasing; see below | +| `icon` | A spell whose spell-bar icon to show, such as `"Confringo"`, or a PNG among the resource's files. The look's by default | +| `art` | Hand-drawn art instead of the drawn stroke: `{ path, mask }`, two PNGs among the resource's files | +| `stroke` | The width of the drawn stroke, 8 to 160 px. 56, the stock lessons' width, by default | + +A lesson needs a `look`, a `path` or both. + +Each checkpoint is `{ at, button, window }`. `at` is how far along the path it sits, between 0 and 1, at least 100 px past the one before. `button` is 1 to 4, and 1 by default. `window` is `[before, after]` in pixels along the path, each 10 to 400: the prompt opens `before` ahead of the checkpoint and counts as missed `after` past it. It is `[150, 100]` by default. Without `checkpoints`, a lesson keeps its look's, and a new path gets one about every 300 px, 4 to 10 in all. + +`chaser` is the bad spark's head start, in seconds. A number is the delay on Normal: Easy doubles it, Hard halves it, and Story has no chaser. Since Easy's delay can't pass 30 seconds, the number can be at most 15. `{ story, easy, normal, hard }` sets each difficulty instead, above 0 and up to 30 seconds, or `null` for no chaser there. `null` alone means no chaser at all. Left out, a lesson keeps its look's, and one with no look has none. A missed button only costs the player a speed boost, so a lesson without a chaser can't be failed. + +PNG paths are relative to the resource's own directory, such as `"img/lesson-path.png"`, and the files must be listed in its manifest's `files`. Hand-drawn art is two 1024 × 1024 images with the stroke in their alpha: `path` is the stroke drawn white, and `mask` is the same stroke shaded from black at its start to white at its end. + +`define` returns `{ ok, reason }`. When it refuses a lesson, `reason` says why: + +| Reason | Meaning | +| --- | --- | +| `invalid-id` | The id is empty, over 32 characters, or holds something other than letters, digits and `_` | +| `stock-name` | The id is the name of one of the game's lessons | +| `too-many` | The resource already has 16 lessons | +| `busy` | That lesson is running | +| `invalid-title` | No title, or one over 128 characters | +| `unknown-look` | `look` is not one of the game's lessons | +| `invalid-path` | Neither `look` nor `path`, or a path outside its limits | +| `invalid-checkpoint` | A checkpoint or `finalButton` outside its limits | +| `invalid-chaser`, `invalid-stroke` | `chaser` or `stroke` outside its limits | +| `unknown-icon` | `icon` names no spell icon | +| `file-not-found`, `invalid-image` | A PNG is not among the resource's files, or doesn't read as an image | +| `unavailable` | The player's game could not take the lesson. The client log says why | + +A resource's lessons are its own: two resources can use the same id. `SpellLearning.lessons()` lists them, `SpellLearning.undefine(id)` removes one, and they go when the resource stops. `define` works before the player is in the world. Neither `define` nor `undefine` can change a lesson while it runs. + +A lesson grants nothing. To teach a defined spell, run its lesson and open its lock when the player completes it: + +```js +// client/main.js +SpellLearning.define("Firestorm", { title: "Firestorm", look: "Confringo", chaser: 4 }); + +Events.on("lessons:start", (data) => SpellLearning.start(data.id)); + +Events.on("spellLearningFinished", (result) => { + if (result.status === "completed") Events.emitServer("lessons:completed", { id: result.spell }); +}); +``` + +```js +// server/main.js +Events.onClient("lessons:completed", (player, data) => { + if (!data || data.id !== "Firestorm" || !mayLearn(player, data.id)) return; + grantSpell(player, "Spell_Firestorm"); // add the lock to the player's stored policy and send it again +}); +``` + +## Where to go next + +- [Progression](/guides/players/progression/#spells) — the spell policy, spell slots and the game's own lessons. +- [Hits and defeat](/guides/pvp/hits-and-defeat/) — how a PvP hit lands, what it costs and what the policy can change. +- [Objects and effects](/guides/world/objects-and-effects/) — the world objects and effects a cast can summon. diff --git a/guides/Players/inventory.md b/guides/Players/inventory.md index 16b1801..6b6f97a 100644 --- a/guides/Players/inventory.md +++ b/guides/Players/inventory.md @@ -1,6 +1,6 @@ --- title: Inventory -description: Give, take and move items in a server-owned inventory, change several things at once safely, save inventories between visits, and adopt items the game hands out. +description: Give, take and move items in a server-owned inventory, change several things at once safely, save inventories between visits, adopt items the game hands out, and decide what gear's stats are worth. sidebar: order: 320 --- @@ -30,6 +30,7 @@ Each row in `list()` is an item: | `unique` | Whether the row is a unique item | | `identified`, `equipped`, `hoodUp` | Gear only: whether it is identified, whether it is worn, and its hood | | `appearanceOverride` | Worn gear only: the gear id whose look it shows, as players choose in the Equipment menu, or `""` for its own | +| `stats` | Gear only: the item's [roll](#gear-stats), or `null` until the player's game has reported it | `InventoryCatalog` describes every item the game knows: `InventoryCatalog.get(itemId)` returns its default holder, stack size and kind, `has(itemId)` tests an id, and `list(filter)` lists definitions, for example every consumable. @@ -56,7 +57,7 @@ player.inventory.give("Edurus", 3, {}, (error, result) => { }); ``` -Ids are checked against the catalog: an unknown one fails with `INVALID_ITEM`, and taking more than a player has fails with `INSUFFICIENT_COUNT`. Gear gets a variation of its own when given, and `options` can hand it over identified, already worn, with its hood up, or showing another piece's look with `appearanceOverride`, a bare gear id. A malformed override fails the whole change: +Ids are checked against the catalog: an unknown one fails with `INVALID_ITEM`, and taking more than a player has fails with `INSUFFICIENT_COUNT`. Gear gets a variation of its own when given, and `options` can hand it over identified, already worn, with its hood up, showing another piece's look with `appearanceOverride`, a bare gear id, or with a roll of [`stats`](#gear-stats). A malformed override or roll fails the whole change: ```js // server/main.js @@ -89,7 +90,7 @@ function buyEdurus(player) { ## Saving inventories -The server writes nothing to disk, so save the inventory whenever it changes and give it back when the player returns. `playerInventoryUpdated` fires with every row and the new revision after each change, and `replace` restores a saved list in one step, gear variations included. Restore it once the player's load screen is gone, as you would [move them to a spawn point](/guides/players/join-and-spawn/#putting-players-in-place): +The server writes nothing to disk, so save the inventory whenever it changes and give it back when the player returns. `playerInventoryUpdated` fires with every row and the new revision after each change, and `replace` restores a saved list in one step, gear variations included. The changes include gear and mounts the player changes in game: identifying a piece, what they wear, their hood, the look a worn piece shows and the mount they have slotted. `player.inventory` still reads inside `playerDisconnect`, so a last save can run there. Restore it once the player's load screen is gone: ```js // server/main.js @@ -103,7 +104,7 @@ Events.on("loadingFinished", async (player) => { }); ``` -Keep the rows as `list()` returns them: `replace` needs a gear row's `variation` to restore that exact piece, and its `appearanceOverride` to restore the look it shows. `saveInventory` and `loadInventory` stand for your own store, such as the database behind [Persisting state](/guides/getting-started/first-resource/#persisting-state). +Keep the rows as `list()` returns them: `replace` needs a gear row's `variation` to restore that exact piece, its `appearanceOverride` to restore the look it shows, and its `stats` to give it back its roll, since a restarted game re-creates gear without one. `saveInventory` and `loadInventory` stand for your own store, such as the database behind [Persisting state](/guides/getting-started/first-resource/#persisting-state). ## Waiting for the player's game @@ -139,7 +140,65 @@ An addition outside the allowlists fails with `NATIVE_DELTA_NOT_ALLOWED`, and a `player.inventory.use(itemId, options, callback)` uses one potion or plant on the player's game, and completes only once the game shows that exact item was used up. The authoritative inventory changes with it and `playerInventoryConsumed` fires. It fails with `ITEM_NOT_USABLE` for an item the game cannot use from the inventory, and `USE_IN_PROGRESS` while another use is still running. +## Gear stats + +Every piece of gear has its own roll: a level, the Offense, Defense and Cooldown it adds to the wearer's, and up to three traits, such as more Confringo damage or less damage taken from trolls. The game rolls it once, when the gear drops as loot, and only the Loom changes it afterwards. A gear row's `stats` holds it: + +| Field | Meaning | +| --- | --- | +| `level` | The item's level, 0 for Basic gear | +| `offense`, `defense`, `cooldown` | What it adds to the wearer's Offense, Defense and Cooldown | +| `upgrades` | How many Loom upgrades it has had | +| `traits` | Its trait ids in slot order, at most 3 | + +`stats` is `null` on every other kind of item. The player's game reports each roll and the server keeps it, so a Loom upgrade is a change like any other and raises `playerInventoryUpdated`. + +Gear from `give` has no roll: the player's game adds it at level 1 with no stats and no traits. Pass `stats` to give it one. Fields you leave out are 0, and the numbers are whole, from 0 to 1000000: + +```js +// server/main.js +player.inventory.give("Back_002_Rare", 1, { + identified: true, + stats: { level: 23, offense: 187, cooldown: 3, traits: ["Gear_Armor_DarkWizard_1"] }, +}); +``` + +`replace` writes a saved roll back the same way, onto each piece the player's game has to re-create. A piece the player still holds keeps the roll it has. If a roll does not take, `waitForRevision` rejects with `NATIVE_APPLY_FAILED`. + +### What worn gear is worth + +What worn gear adds is a server rule, apart from its roll. `player.setGearStatMultiplier(multiplier)` scales the Offense, Defense and Cooldown every worn piece adds, which the game turns into its gear damage bonus and damage reduction: 1 is the full roll, and 0 is as if the player wore no gear. `player.setGearTraits(false)` stops worn gear's traits from applying. Together they make gear cosmetic, for example in an arena: + +```js +// server/main.js +function enterArena(player) { + player.setGearStatMultiplier(0); + player.setGearTraits(false); +} + +function leaveArena(player) { + player.setGearStatMultiplier(null); + player.setGearTraits(null); +} +``` + +`null` goes back to `mod.gearStatMultiplier` and `mod.gearTraits` in [`server.json`](/guides/hosting-a-server/configuration/#mod-keys), which set the rules for every joining player. `player.gearStatMultiplier` and `player.gearTraits` read them back. The player's game re-applies their gear at once and keeps the rules over respawns and travel. A multiplier outside 0 to 1 throws. + +The rules change only what the gear adds. Each row's `stats` and the item tooltips keep the real roll, so a piece is worth all of it again once the rule is lifted. + +### Reading a roll on the client + +`LocalPlayer.gearStats(itemId, variation)` reads the roll of one piece the local player owns, worn or not, with whether it is `identified`. It is the item's own roll, whatever the server's rules, or `null` when the player owns no such piece: + +```js +// client/main.js +const robe = LocalPlayer.equippedGear()?.Back; +const roll = robe ? LocalPlayer.gearStats(robe.itemId, robe.variation) : null; +if (roll) console.log(`Robe level ${roll.level}: offense ${roll.offense}, defense ${roll.defense}`); +``` + ## Where to go next - [Join and spawn](/guides/players/join-and-spawn/) for restoring saved data when a player connects. - [Authority](/guides/core-concepts/authority/) for why client reports never grant items. +- [Appearance](/guides/players/appearance/#clothes) for dressing players in the gear they own. diff --git a/guides/Players/join-and-spawn.md b/guides/Players/join-and-spawn.md index 136d108..8f37d3d 100644 --- a/guides/Players/join-and-spawn.md +++ b/guides/Players/join-and-spawn.md @@ -12,9 +12,9 @@ A player's visit raises a fixed series of events on the server. This page follow | Event | When it fires | Use it to | | --- | --- | --- | | `playerConnecting` | A player asks to join, before they hold a slot or receive anything | Turn players away: bans, whitelists, queues, join tickets | -| `playerConnect` | The player has connected | Load their saved data and set their state | +| `playerConnect` | The player has connected | Load their saved data, set their state and their spawn point | | `worldReady` | Their world, character and replication are ready | | -| `loadingFinished` | Their load screen is gone | Move them to a spawn point, open your UI | +| `loadingFinished` | Their load screen is gone | Restore their look and inventory, open your UI | | `playerDisconnect` | They are leaving; their handle still works inside the handler | Save their data and clean up | The server also announces every join and departure in chat by itself. @@ -40,21 +40,64 @@ Events.on("playerConnecting", async (connection) => { ## Putting players in place -To choose where a player starts, teleport them once their load screen is gone. `player.teleport(x, y, z, options)` queues the move on the player's client and returns a request id, and `playerTeleportComplete` reports how it went: +A spawn point decides where a player's game stands them when it loads them into the world. Without one, a joining player starts where the game does, in their house dormitory. Once they are in the world, a teleport moves them. + +### Spawn points + +`World.setSpawn(spawn)` sets the spawn point for every player, and `player.setSpawn(spawn)` sets one for a single player. A spawn point is a `position` in Overland, in world centimetres, and a `yaw` to face, in degrees, 0 when left out: + +```js +// server/main.js +World.setSpawn({ position: { x: 366784, y: -461527, z: -82610 }, yaw: -130 }); + +const lastPositions = new Map(); // hardware id -> where the player stood when they last left + +Events.on("playerConnecting", async (connection) => { + const saved = await loadLastPosition(connection.hardwareId); + if (saved) lastPositions.set(connection.hardwareId, saved); +}); + +Events.on("playerConnect", (player) => { + const saved = lastPositions.get(player.hardwareId); + lastPositions.delete(player.hardwareId); + if (saved) player.setSpawn({ position: { x: saved.x, y: saved.y, z: saved.z }, yaw: saved.yaw }); +}); +``` + +A spawn point applies to two loads: + +- The load that brings a joining player into the world. They arrive straight at the spawn point and never pass through the dormitory. +- A respawn after a death that names no position. See [Deaths and respawns](#deaths-and-respawns). + +The player's own spawn point comes first, then the world's, then the game's own start. `player.getSpawn()` and `World.spawn` read them back, or `null` when unset. `player.setSpawn(null)` hands the player back to the world's spawn point, and `World.setSpawn(null)` hands everyone back to the game's start. A malformed spawn point makes `World.setSpawn` return `false` and `player.setSpawn` throw. + +For the joining load, call `player.setSpawn` inside a `playerConnect` handler, before it awaits anything. Once the load has begun, a new spawn point only applies to later respawns. That is why the sample fetches the saved position in `playerConnecting`, whose handlers the join waits for. A player's spawn point lasts for their connection, so set it again on each visit. A `World.setSpawn` change applies from the next join or respawn and moves nobody who is already in the world. + +Both loads bring the player into Overland, so a spawn point has no map of its own. To start a player in a dungeon or a mod's map, send them there with [`travelToMap`](/guides/players/travel/#loading-another-map) once they have loaded. + +### Teleporting + +`player.teleport(x, y, z, options)` moves a player within the map they are in. It queues the move on the player's client and returns a request id, and `playerTeleportComplete` reports how it went. This `/spawn` command takes a player back to their spawn point: ```js // server/main.js -const spawnPoint = { x: 0, y: 0, z: 0, yaw: 90 }; // your spawn point, in world centimetres const spawning = new Map(); -Events.on("loadingFinished", (player) => { - const requestId = player.teleport(spawnPoint.x, spawnPoint.y, spawnPoint.z, { yaw: spawnPoint.yaw, snapToGround: true }); +Events.on("chatCommand", (player, message, command) => { + if (command.toLowerCase() !== "spawn") return; + const spawn = player.getSpawn() ?? World.spawn; + if (!spawn || player.location()?.areaId !== "Overland") { + player.sendChat("There is no spawn point to go back to from here."); + return; + } + const { x, y, z } = spawn.position; + const requestId = player.teleport(x, y, z, { yaw: spawn.yaw, snapToGround: true }); if (requestId > 0) spawning.set(requestId, player.id); }); Events.on("playerTeleportComplete", (player, requestId, status) => { if (!spawning.delete(requestId)) return; - if (status !== 0) console.warn(`spawn teleport for ${player.nickname} ended with status ${status}`); + if (status !== 0) player.sendChat(`Could not take you to your spawn point (status ${status}).`); }); ``` @@ -66,7 +109,7 @@ Events.on("playerTeleportComplete", (player, requestId, status) => { The completion's `status` is 0 for success, 1 when a newer teleport replaced this one, 2 when the player's game could not teleport yet, 3 when they changed worlds first, 4 when the teleport failed and 5 when it timed out. -A teleport stays within the map the player is in. Moving between maps takes a fast-travel point: `player.fastTravel(name)` takes one of the game's `FT_` destinations, such as `FT_CentralHogsmeade`, returns a request id, or 0 for a name it does not know, and reports through `playerFastTravelComplete`. Its `status` is 0 for success, 1 when superseded, 2 when the player's game could not travel yet, 3 when their game does not know the destination and 4 when it timed out. +A teleport stays within the map the player is in. To move them to another map, a fast-travel point or a place the game builds from several maps, see [Travel between places](/guides/players/travel/). `player.location()` returns the map and region the player is in together with their position, or `null` until they reach a playable world and while they change worlds. `playerLocationChanged` fires whenever that changes. Both are reported by the player's client. @@ -74,15 +117,33 @@ A teleport stays within the map the player is in. Moving between maps takes a fa When a player dies, `playerDied` fires. By default they respawn on their own a few seconds later, and `playerDownedEnd` fires once they are back up. `playerDowned` fires when a player is knocked into the near-death kneeling state, and `playerDownedEnd` ends that too. `player.isDead()` is `true` from `playerDied` until `playerDownedEnd`. -Whether players respawn on their own is `mod.autoRespawn` in [`server.json`](/guides/hosting-a-server/configuration/#respawning), on by default. To decide for one death, call `player.setAutoRespawn(false)` inside the `playerDied` handler and respawn the player yourself with `player.respawn()`. It reloads Overland for them and stands them at the position you give, or at the game's default start without one: +Whether players respawn on their own is `mod.autoRespawn` in [`server.json`](/guides/hosting-a-server/configuration/#respawning), on by default. A respawn reloads Overland for the player and stands them at their [spawn point](#spawn-points), or at the game's default start without one. + +To decide for one death, call `player.setAutoRespawn(false)` inside the `playerDied` handler. The player stays where they fell, still watching the world, until you bring them back one of two ways: + +| Call | What it does | +| --- | --- | +| `player.respawn(options)` | Reloads Overland and stands them at `options.position`, facing `options.yaw`. Without a position, they stand at their spawn point. | +| `player.revive(options)` | Stands them up where they lie, with no reload: the game's own get-up plays and the world around them stays as it is. `options.health` is the health they get up with, up to their maximum; left out or `"max"`, they get up at full health. | + +Both return `false` for a player who is not dead, and `playerDownedEnd` fires once they are up. A kneeling player is not dead, so `revive` does not apply to them; a kneel ends on its own, as [Defeat](/guides/pvp/hits-and-defeat/#defeat) explains. + +`revive` also returns `false` once a respawn or another revive is on its way. With auto-respawn on, the server orders the respawn as soon as the `playerDied` handlers have run, so revive a player inside the handler, or hold the death with `setAutoRespawn(false)` and revive them later. Here a friend has 30 seconds to revive a fallen player before they respawn: ```js // server/main.js Events.on("playerDied", (player) => { player.setAutoRespawn(false); + player.sendChat("You are down. A friend has 30 seconds to /revive you."); setTimeout(() => { - if (player.connected && player.isDead()) player.respawn({ position: spawnPoint, yaw: spawnPoint.yaw }); - }, 10000); + if (player.connected && player.isDead()) player.respawn(); + }, 30000); +}); + +Events.on("chatCommand", (player, message, command, args) => { + if (command !== "revive") return; + const fallen = PlayerManager.getAll().find((other) => other.nickname === args[0]); + if (!fallen?.revive({ health: fallen.maxHealth / 2 })) player.sendChat(`${args[0] ?? "Nobody"} cannot be revived.`); }); ``` diff --git a/guides/Players/progression.md b/guides/Players/progression.md index b4d9adf..8ce7053 100644 --- a/guides/Players/progression.md +++ b/guides/Players/progression.md @@ -109,6 +109,8 @@ A spell's lock is `Spell_` followed by the game's own name for it, which is not `Spells.setLoadoutSlot(slot, spellName)` puts an open spell in one of the four slots of the current spell set, and `Spells.loadout()` reads a set back. `spellName` is the lock without `Spell_`, such as `"Expulso"` for Bombarda. +A server can also define spells of its own, each with a lock of `Spell_` followed by its id. See [Custom spells](/guides/players/custom-spells/). + ### Spell lessons `SpellLearning.start(spell)` plays the game's lesson for a spell, one of `SpellLearning.supportedSpells()`. When it ends, the client raises `spellLearningFinished` with a `status` of `"completed"`, `"cancelled"` or `"failed"`. A lesson does not unlock anything, so open the spell yourself when it completes: @@ -122,6 +124,8 @@ Events.on("spellLearningFinished", (result) => { The server then adds the spell's lock to the player's policy. +A resource can also add lessons of its own, from a stock lesson's look or a path it draws, with its own buttons and chaser. See [Lessons of your own](/guides/players/custom-spells/#lessons-of-your-own). + ## Talents Talents need the player in the world: `Talents.available()` is `false` until then. The calls that change a talent return whether the game did it, and the change takes effect at once. diff --git a/guides/Players/travel.md b/guides/Players/travel.md new file mode 100644 index 0000000..3834b1a --- /dev/null +++ b/guides/Players/travel.md @@ -0,0 +1,151 @@ +--- +title: Travel between places +description: Teleport within a map, fast travel to the game's own points, load any map, and reach places the game builds from several maps. Which call to use, and how each one reports back. +sidebar: + order: 305 +--- + +Hogwarts Legacy is not one map. Overland is one, and it holds the open world, the castle and Hogsmeade. Every dungeon is a map of its own, and a mod can add more. Players only see and hear others in the same map, so moving someone to another map also moves them out of everyone else's view (see [Virtual worlds](/guides/core-concepts/virtual-worlds/#worlds-and-maps)). + +Four calls move a player. Each queues the move on the player's client and returns a request id, and an event reports how it went. + +## Which call to use + +| Call | Use it for | Where they land | Reported by | +| --- | --- | --- | --- | +| `player.teleport(x, y, z, options)` | A spot in the map the player is already in | The position you give | `playerTeleportComplete`, once they stand there | +| `player.fastTravel(name)` | Anywhere the game has a fast-travel point | The game's own point | `playerFastTravelComplete`, once the game accepts the trip | +| `player.travelToMap(map, options)` | Another map: a dungeon, a mod's map, or Overland at a spot | A position, a start tag, or the map's own start | `playerMapTravelComplete`, once they can play there | +| `player.travelToPlace(name)` | A place the game builds from several maps, such as the intro's Gringotts | The place's own spawn | `playerMapTravelComplete`, once they can play there | + +- If the game has a fast-travel point there, use `fastTravel`. It is the game's own trip, with its own transition and landing. +- For another map, or an exact spot the game has no point for, use `travelToMap`. Within the map the player is already in, use `teleport`: `travelToMap` to that map reloads all of it just to move them. +- For a place no single map makes, use `travelToPlace`. + +When something has to happen after the player arrives, such as starting an encounter, wait for `playerMapTravelComplete`. `playerFastTravelComplete` fires as soon as the game accepts the trip, before the player lands. + +## Teleporting within a map + +`player.teleport` moves a player to world coordinates in the map they are in, and never across maps. Its options and statuses are in [Join and spawn](/guides/players/join-and-spawn/#teleporting). + +## Fast travel + +`player.fastTravel(name)` takes one of the game's fast-travel ids, matched case-insensitively. The ids name Floo flames and other points the game travels to, such as `FT_CentralHogsmeade` or `FT_HW_GreatHall`. A trip to a point in a dungeon, such as `FT_DeathHallows`, changes maps too. + +It returns 0 for a name it does not know, without asking the player's game. Otherwise `playerFastTravelComplete` reports the `status`: + +| Status | Meaning | +| --- | --- | +| 0 | The game accepted the trip. It does not mean the player has arrived. | +| 1 | A newer request replaced this one. | +| 2 | The player's game could not travel yet. | +| 3 | Their game does not know the destination. | +| 4 | Their game never answered. | + +```js +// server/main.js +Events.on("chatCommand", (player, message, command) => { + if (command.toLowerCase() !== "hogsmeade") return; + if (player.fastTravel("FT_CentralHogsmeade") === 0) player.sendChat("That destination does not exist."); +}); +``` + +### Players' own fast travel + +A player can also fast travel from their own map screen. `player.setFastTravelEnabled(false)` stops that for one player: their game dims every Floo flame and refuses a pick without asking the server. Story travel and `player.fastTravel` still work. It is on by default, and `player.getFastTravelEnabled()` reads it back. + +While it is on, each pick raises `playerFastTravelRequest(player, request)`, and the player's game holds the trip until the server answers. `request.destinationId` is the fast-travel id they picked. Return `false` from the handler, synchronously, to refuse it, and the player stays on their map screen. + +```js +// server/main.js +Events.on("playerFastTravelRequest", (player, request) => { + if (request.destinationId === "FT_HW_RoomOfRequirement" && !hasRoomKey(player)) return false; +}); +``` + +## Loading another map + +`player.travelToMap(map, options)` loads any map by its package path. A path names the map's package, such as `"/Game/Levels/Overland/Overland"`, a dungeon such as `"/Game/Environment/Azkaban/Maps/Azkaban_Dungeon"`, or a mod's `"//Maps/"`. To find a mod map's path, load it in the game and read the `LoadMap` line in the client log. The path's last segment is the `areaId` that `player.location()` reports there. + +| Option | Meaning | +| --- | --- | +| `position` | Where to stand the player, in that map's coordinates, as `{ x, y, z }`. | +| `yaw` | The heading to face at `position`, in degrees. | +| `playerStart` | Instead of `position`, a start tag, the way the game's own dungeon exits travel. A PlayerStart with that tag, else a dungeon of that name. On Overland, a dungeon's name lands at that dungeon's entrance. | +| `levels` | Up to 8 more maps to load into the destination. See [Extra levels](#extra-levels). | + +Without `position` or `playerStart`, the player lands at the map's own start. Malformed paths and options throw before anything is sent. + +The player's game checks the map is installed first and declines it otherwise, so a mod's map only works when the server requires that mod. List it in [Required CurseForge mods](/guides/hosting-a-server/configuration/#required-curseforge-mods). + +`playerMapTravelComplete(player, requestId, status, completion)` fires once the player can play in the new map: + +| Status | Meaning | +| --- | --- | +| 0 | The player arrived. | +| 1 | A newer request replaced this one. | +| 2 | The player's game could not travel yet. | +| 3 | Declined: the map, or one of `levels`, is not installed on this player's game. | +| 4 | Their game never answered. | +| 5 | The load ended in another map, which `completion.areaId` names. | +| 6 | The player arrived, but one of `levels` did not load. | + +`completion` carries the `map` you asked for, the `place` it came from (see below, `null` here), the `areaId` the player settled in and the `destination` they stand at. `areaId` and `destination` are `null` unless the load settled. + +```js +// server/main.js +const DIAGON_ALLEY = "/Sil_LondonDungeon/Maps/Sil_LondonDungeon_DungeonMap"; // CurseForge 1520949 + +function enterDiagonAlley(player) { + player.travelToMap(DIAGON_ALLEY); +} + +// Back out at the alley's Overland entrance, the way walking through its exit does. +function leaveDiagonAlley(player) { + player.travelToMap("/Game/Levels/Overland/Overland", { playerStart: "Sil_LondonDungeon_DungeonMap" }); +} + +Events.on("playerMapTravelComplete", (player, requestId, status, completion) => { + if (status === 3) player.sendChat("This server's Diagon Alley mod is not installed."); +}); +``` + +### Extra levels + +Some areas are no single map: the game loads several into one. `levels` lists up to 8 more maps by package path, loaded into the destination before the player is placed, as the map's own always-loaded parts are. They must differ from each other and from the map. The player's game declines the whole trip if one is not installed (status 3), and status 6 means the player arrived without one of them. + +Most of the time you do not need `levels`: every dungeon brings its own parts, and `travelToPlace` knows the game's own sets. + +## Places the game builds + +Some places in the game have no map of their own to load. The game assembles them from several maps, from a story script a multiplayer session never runs. Loaded with `travelToMap` alone, such a place arrives in the dark or with parts missing. + +`player.travelToPlace(name)` knows these places: their maps, the parts the game loads with them, and the spawn the game uses there. + +| Name | Place | +| --- | --- | +| `intro.gringotts` | The intro's Gringotts, at the lobby doors. | +| `intro.vault` | The trial chamber below the Gringotts vault. As in the game, it is dark and needs Lumos. | +| `intro.cliffside` | The intro's sunlit cliffside and ruins. | + +It returns 0 for a name that is no place, matched case-insensitively. Otherwise it is a `travelToMap` to the place's map, so `playerMapTravelComplete` reports it with the same statuses and `completion.place` set to its name. + +```js +// server/main.js +Events.on("chatCommand", (player, message, command) => { + if (command.toLowerCase() !== "gringotts") return; + player.travelToPlace("intro.gringotts"); +}); + +Events.on("playerMapTravelComplete", (player, requestId, status, completion) => { + if (completion.place && status !== 0) player.sendChat(`Could not reach ${completion.place} (status ${status}).`); +}); +``` + +Each place is a map of its own. Players there only meet others who went to the same place, and a respawn, on its own or through `player.respawn()`, reloads Overland. To bring a player back, `travelToMap` to Overland with a `position`, or `fastTravel` to a point. + +## Where to go next + +- [Join and spawn](/guides/players/join-and-spawn/) for spawn points, deaths and respawns. +- [Virtual worlds](/guides/core-concepts/virtual-worlds/) for how maps and worlds decide who sees whom. +- [Server configuration](/guides/hosting-a-server/configuration/#required-curseforge-mods) for requiring the mods whose maps you load. diff --git a/guides/Players/walking-players.md b/guides/Players/walking-players.md new file mode 100644 index 0000000..f576e9c --- /dev/null +++ b/guides/Players/walking-players.md @@ -0,0 +1,184 @@ +--- +title: Walking and seating players +description: Walk a player to a point or after another player from the server, seat them exactly on furniture from a client script, and land a scripted flight without a deadly fall. +sidebar: + order: 307 +--- + +A server script can walk a player's own character, to a point or after another player. The player's game does the walking with its own on-foot movement, so they and everyone around them see an ordinary walk. Client scripts can also seat the local player exactly on a bench or chair, and fly them without the landing counting as a fall. + +To put a player somewhere at once, or in another map, see [Travel between places](/guides/players/travel/). + +## Walking to a point + +`player.moveTo(x, y, z, options)` walks a player to a point in the map they are in. `z` is the height of the player's origin, as in `player.position`. The options need an `owner`, a tag of your choosing; see [Owners](#owners). + +The player's game finds its own way around walls and furniture, and walks until they are within `acceptRadius` of the point and within 3 m of its height. `playerMoveEnded` then fires with `"arrived"`. + +```js +// server/main.js +function marchToCell(player) { + player.moveTo(cell.x, cell.y, cell.z, { owner: "jail:march", acceptRadius: 50, ttlMs: 60000 }); +} + +Events.on("playerMoveEnded", (player, owner, reason) => { + if (owner !== "jail:march") return; + if (reason === "arrived") player.sendChat("Welcome to your cell."); + else if (reason === "blocked" || reason === "expired") player.teleport(cell.x, cell.y, cell.z); +}); +``` + +`moveTo` returns `false` when the player has no live character. + +## Following another player + +`player.follow(target, options)` walks a player after another, given as a player or their id. The follower walks until they are within `distanceCm` of the target, waits there, and sets off again once the target is 100 cm further away. A follow never arrives: it runs until you stop it, another walk replaces it, its `ttlMs` runs out or the target is lost. + +```js +// server/main.js +// "/escort 12" walks player 12 after the guard who typed it, for up to five minutes. +Events.on("chatCommand", (player, message, command, args) => { + if (command.toLowerCase() !== "escort" || !isGuard(player)) return; + const prisoner = PlayerManager.getById(Number(args[0])); + if (!prisoner || prisoner.id === player.id) return; + if (!prisoner.follow(player, { owner: "jail:escort", distanceCm: 150, ttlMs: 5 * 60 * 1000 })) { + player.sendChat("That player cannot walk right now."); + } +}); +``` + +`follow` returns `false` when either player has no live character, and throws when a player is told to follow themselves. + +## Walk options + +| Option | Meaning | +| --- | --- | +| `owner` | Required. A tag naming whose walk it is; see [Owners](#owners) | +| `gait` | `"walk"`, the default, at about 156 cm/s, or `"jog"`, at about 450 cm/s | +| `speed` | A pace from 80 to 450 cm/s, instead of `gait` | +| `ttlMs` | Ends the walk with `"expired"` after this many milliseconds. 0, the default, lets it run until it ends by itself | +| `path` | `"nav"`, the default, finds a way on the navmesh and walks straight where there is none; `"direct"` always walks straight | +| `lockInput` | `true` by default: the player's own controls stop while the walk runs. See [Input during a walk](#input-during-a-walk) | +| `acceptRadius` | `moveTo` only. How close counts as arrived, from 10 to 2000 cm: 75 by default | +| `distanceCm` | `follow` only. How close the follower stays, from 50 to 3000 cm: 200 by default | + +A missing `owner`, an unknown `gait` or `path`, a negative `ttlMs` or a number out of range throws. + +## Owners + +A player walks one walk at a time. Starting another, with any owner, ends the running one with `"replaced"`. The `owner` tag says whose walk it is: `playerMoveEnded` hands it back, so each resource can pick out its own walks. Every resource shares the same tags, so start yours with your resource's name. + +`player.stopMoving(owner)` stops the player where they stand and ends the walk with `"released"`, but only if that owner started it. It returns `false` when no walk of that owner runs. Without an owner, it ends whichever walk runs. + +Nothing ends a walk when the resource that started it stops, so a follow without a `ttlMs` keeps going until something else ends it. + +`player.moving()` reads the walk a player is on, or returns `null`: + +| Field | Meaning | +| --- | --- | +| `owner` | The tag it was started with | +| `mode` | `"to"` for `moveTo`, `"follow"` for `follow` | +| `x`, `y`, `z` | `moveTo`'s point | +| `targetId` | The id of the player `follow` walks after | +| `gait`, `speed` | The gait asked for, and the speed, or `null` without one | +| `radius` | Its `acceptRadius` or `distanceCm` | +| `expiresAt` | The server clock time, in milliseconds, its `ttlMs` ends it at; 0 without one | + +## When a walk ends + +`playerMoveEnded(player, owner, reason)` fires when a walk ends, with one of these reasons: + +| Reason | Meaning | +| --- | --- | +| `arrived` | A `moveTo` reached its point. The server checks this against where it last saw the player, and reports an arrival it cannot confirm as `blocked` | +| `blocked` | The walk made no headway for 5 seconds: a wall, a locked door, a crowd | +| `targetLost` | A follow's target left the server, or the follower's game has not seen them for 2 seconds, for example because they went to another map | +| `interrupted` | The player mounted, swam, was knocked down or was taken by a scene for more than 3 seconds | +| `travel` | The player was teleported, fast travelled, changed maps or respawned. Calling `player.teleport`, `fastTravel`, `travelToMap` or `travelToPlace` on them ends a walk this way at once | +| `died` | The player died | +| `released` | `stopMoving` ended it | +| `replaced` | Another `moveTo` or `follow` took its place | +| `expired` | Its `ttlMs` ran out | +| `disconnect` | The player left | + +The walk runs in the player's own game, as all their movement does, so a modified client can ignore it. To keep a player somewhere whatever their client does, hold them there with `player.hold` once they arrive: their client cannot undo a hold (see [Authority](/guides/core-concepts/authority/#what-the-server-owns)). + +## Input during a walk + +With `lockInput`, the default, the player's own movement, casting and other controls stop while the walk runs, and come back when it ends. The camera stays theirs to turn. With `lockInput: false` they keep their controls, but the walk still does the steering. + +A walk's hold on the player's input sits beside the holds client scripts take with [`LocalPlayer.stopPlayerInput()`](/guides/user-interface/keys-and-menus/#locking-the-controls), and input stays stopped while anything holds it. So the end of a walk never frees a player a client script still holds, and a script's `restorePlayerInput()` returns `false` while a walk still holds them. + +## Seating the player + +A seated body overlaps its seat, and the other ways of placing a player resolve that overlap: `LocalPlayer.setPosition` and `player.teleport` push them clear of it. `LocalPlayer.anchor(at)` puts the local player exactly on a point and holds them there, with no gravity and nothing pushing them out, while their collision stays on. + +`at` is the sit point at floor level, as `x`, `y` and `z`. The height of the body is added for you, so a resized character sits right. `yaw` is the way to face, by default the way they face now, and `camera: true` turns the camera with them. Calling `anchor` again seats them somewhere else. It returns `false` with no character, or while the player is dead, kneeling, riding, held by the server or mid-teleport. + +On the game's own benches and chairs, the sit point is in front of the seat and the player faces away from it, because the seated clips carry the body back onto the seat. Anchoring at the middle of the seat puts their legs inside the furniture. The station copies of those meshes, under `/Game/Gameplay/Stations/`, mark the point with an `AnimStart` socket. + +Anchoring only places the body. Play a seated pose with `LocalPlayer.playClip`, holding its last frame. Other players see the pose, and the body where it sits, without any server call. + +```js +// client/main.js +const BENCH_CLIP = "/Game/Animation/Human/Stations/Sitting/HUF_STN_SIT_Bench_Idle_EatingApple_ANM.HUF_STN_SIT_Bench_Idle_EatingApple_ANM"; +let seat = null; + +// The server sends a bench's sit point and facing, and a spot clear of the table to stand up at. +Events.on("tavern:sit", (bench) => { + if (!LocalPlayer.anchor({ x: bench.x, y: bench.y, z: bench.z, yaw: bench.yaw, camera: true })) return; + LocalPlayer.playClip(BENCH_CLIP, { loop: false, hold: true }); + seat = bench; +}); + +// Movement keys do nothing while seated, so standing up needs a key of its own. +Key.bind("f3", () => { + if (seat) LocalPlayer.releaseAnchor(seat.exit); +}); + +// Whatever ended the seat, the seated pose ends with it. +Events.on("anchorReleased", () => { + LocalPlayer.stopEmote(); + seat = null; +}); +``` + +`LocalPlayer.releaseAnchor(exit)` stands the player up at `exit`, a floor-level point with an optional `yaw`, placed clear of anything in the way, so a player seated under a table comes out beside it. Without an `exit` they stand up where they are, and the game pushes them out of whatever they overlap, in whatever direction it picks. It returns `false` when the player was not seated. + +`LocalPlayer.getAnchor()` returns the point and `yaw` the player is seated at, or `null`. `LocalPlayer.setRotation(yaw, { camera })` turns the player's body, and walking turns it again as usual; while they are seated it turns the seat. It returns `false` with no character or while they ride something. + +### When a seat ends + +`anchorReleased(reason, at)` fires the frame after a seat ends, with `at`, the floor-level point the player sat at, and one of these reasons: + +| Reason | Meaning | +| --- | --- | +| `script` | `releaseAnchor` ended it | +| `death` | The player died | +| `kneel` | The player went down to their knees, as after a PvP defeat | +| `mount` | The player got on a broom or a creature, or took an animal form | +| `teleport` | A teleport, a fast travel or `setPosition` moved them, or something else moved them off the seat | +| `hold` | The server put a hold on them | +| `travel` | A map started loading | +| `pawn` | The player's character was replaced by a new one | + +## Landing a scripted flight + +A client script can fly the local player itself: `LocalPlayer.setCollision(false)` lets them pass through the world, and `LocalPlayer.setPosition(x, y, z)` moves them a short step at a time. The game only stops counting a fall after a teleport of more than 1 m, so to it a flight of short steps is one long fall. When collision comes back, the landing counts about the height gained since the flight began plus the real drop. Past 16 m that kills the player, even one made invulnerable with `LocalPlayer.stateInfo.setInvulnerableToDamage`. + +`LocalPlayer.resetFall()` treats the player as just teleported: the next landing deals no fall damage, and a later fall is measured from where they are now. Call it before turning collision back on. It returns `false` with no character. + +```js +// client/main.js +function stopFlying() { + clearInterval(flightTimer); + LocalPlayer.resetFall(); + LocalPlayer.setCollision(true); +} +``` + +## Where to go next + +- [Travel between places](/guides/players/travel/) for teleports, fast travel and loading other maps. +- [Keys and menus](/guides/user-interface/keys-and-menus/#locking-the-controls) for stopping a player's input, jumping and sprinting from a client script. +- [Camera](/guides/camera-and-sound/camera/) for taking over the view while a walk or a seat plays out. diff --git a/guides/PvP/hits-and-defeat.md b/guides/PvP/hits-and-defeat.md index faa14a5..cf26fa2 100644 --- a/guides/PvP/hits-and-defeat.md +++ b/guides/PvP/hits-and-defeat.md @@ -36,7 +36,7 @@ The server sets the damage of every hit itself, and ignores the attacker's game' | Crucio | 4 a second for 20 seconds | | Avada Kedavra | Lethal | -Every other spell does no damage but still has its effect. `Pvp.setSpellDamage(spell, damage)` replaces a spell's price and `null` restores it. The price is then multiplied by the `damageScale` rule, the attacker's `player.damageGivenMultiplier` and the victim's `player.damageTakenMultiplier`, set with `setDamageGivenMultiplier` and `setDamageTakenMultiplier`. The Killing Curse stays lethal unless you set its price yourself. +Every other spell of the game's does no damage but still has its effect. Spells the server defines and spells mods add are priced as [Custom spells](/guides/players/custom-spells/#spells-in-pvp) describes. `Pvp.setSpellDamage(spell, damage)` replaces a spell's price and `null` restores it. The price is then multiplied by the `damageScale` rule, the attacker's `player.damageGivenMultiplier` and the victim's `player.damageTakenMultiplier`, set with `setDamageGivenMultiplier` and `setDamageTakenMultiplier`. The Killing Curse stays lethal unless you set its price yourself. ## The policy hook diff --git a/guides/PvP/rules-and-relations.md b/guides/PvP/rules-and-relations.md index 20b4c98..a284510 100644 --- a/guides/PvP/rules-and-relations.md +++ b/guides/PvP/rules-and-relations.md @@ -25,6 +25,7 @@ Out of the box nobody can hurt anybody. PvP starts when a script sets rules or p | `flagCombatLockSeconds` | 15 | How long after a hit, given or taken, a player who drops their flag stays flagged, and stays in a fight for [spell cooldowns](/guides/pvp/hits-and-defeat/#spell-cooldowns) | | `spawnProtectionSeconds` | 5 | How long a player killed in PvP cannot be hurt once they are back up; their own hit ends it | | `damageScale` | 1 | Multiplies every spell's [damage](/guides/pvp/hits-and-defeat/#damage) | +| `moddedSpellCap` | 100 | The most a hit by a [modded spell](/guides/players/custom-spells/#modded-spells) can cost, before `damageScale` | | `cooldowns` | `"full"` | `"full"` gives spells their whole [cooldown](/guides/pvp/hits-and-defeat/#spell-cooldowns) in a PvP fight; `"fast"` keeps the game's out-of-combat recharge | ```js diff --git a/guides/Room of Requirement/doors-and-visits.md b/guides/Room of Requirement/doors-and-visits.md index a82201c..78f70df 100644 --- a/guides/Room of Requirement/doors-and-visits.md +++ b/guides/Room of Requirement/doors-and-visits.md @@ -1,11 +1,11 @@ --- title: Doors and visits -description: Decide where each player's door to the Room of Requirement leads, take players into rooms, invite visitors, and know who is in which room. +description: Decide where each player's door to the Room of Requirement leads, let players in through it or take them into rooms directly, invite visitors, and know who is in which room. sidebar: order: 560 --- -Players reach the Room of Requirement through its door in the castle, or by fast travel. Where they arrive is up to the server: each player has a destination, the room their door leads to, and the server can also take a player into any room at once. +Players reach the Room of Requirement by fast travel, or through its door in the castle when a script lets them in. Where they arrive is up to the server: each player has a destination, the room their door leads to, and the server can also take a player into any room at once. ## Destinations @@ -59,17 +59,72 @@ Events.on("chatCommand", (player, message, command, args) => { ## The door -The door in the castle is one door that everyone sees. `Sanctuary.setDoor(mode)` sets how it behaves for everyone: +The door in the castle is one door that everyone sees. `Sanctuary.setDoor(mode)` sets when it shows, for everyone: | Mode | The door | | --- | --- | -| `"proximity"` | Appears while a player who may enter stands at it, and everyone watching sees it appear. The default. | +| `"proximity"` | Appears while any player stands at it, and everyone watching sees it appear. The default. | | `"open"` | Always shows | | `"closed"` | Never shows, so nobody walks in | -`Sanctuary.setDoorAccess(player, false)` keeps the door shut for one player. They still see it appear for others, but cannot go through, and it never appears for them. `Sanctuary.doorAccess(player)` is true when a player may go through: they have access, and their destination is loaded. +Going through is decided per player. `Sanctuary.doorAccess(player)` is true when a player may: they have access, and their destination is loaded. `Sanctuary.setDoorAccess(player, false)` takes a player's access away, and `true` gives it back. A player without access still makes the door appear and sees it, but cannot go through. -The door is not a lock. Fast travel to the Room of Requirement ignores door access, and the door itself is drawn by each player's game. What keeps a player out of a room is that the server only sends a room to players whose destination it is, who are entering it or who are in it. To bar a player, clear their destination as well. +### Letting players in + +The game's own walk-in through the door, with its F prompt, is off by default. The door still appears, but nobody goes through it until a script takes them in: the client shows its own prompt at the door, and the server calls `Sanctuary.enter`. `Sanctuary.setNativeEntry(true)` turns the game's walk-in back on for players with access, and `Sanctuary.nativeEntry()` reads the setting. + +On the client, `Sanctuary.door()` reports the door as this player sees it: + +| Field | Meaning | +| --- | --- | +| `found` | The door is loaded | +| `shown` | It is showing | +| `localNear` | This player stands at it | +| `access` | This player's door access | +| `nativeEntry` | The game's own walk-in is on | +| `x`, `y`, `z` | Where to anchor a prompt, in world centimetres. 0 until the door is found. | + +This client script shows a prompt at the door, and asks the server to take the player in when they press F: + +```js +// client/main.js +let atDoor = false; + +setInterval(() => { + const door = Sanctuary.door(); + const want = door.found && door.shown && door.localNear && door.access && !door.nativeEntry; + if (want === atDoor) return; + atDoor = want; + if (want) Hud.showPrompt(Key.getLabel("f"), "Enter", door.x, door.y, door.z); + else Hud.hidePrompt(); +}, 250); + +Key.bind("f", () => { + if (atDoor) Events.emitServer("room:door"); +}); +``` + +The server checks again that the player stands at the door and has access, since any client can send the request. Which room they enter is yours to choose. This takes them to their destination: + +```js +// server/main.js +const DOOR = { x: 348658, y: -458032, z: -79920 }; // the door, in Overland +const lastTrip = new Map(); // player id -> when their last trip began + +Events.onClient("room:door", (player) => { + const at = player.location(); + if (at?.areaId !== "Overland" || Math.hypot(at.x - DOOR.x, at.y - DOOR.y, at.z - DOOR.z) > 600) return; + if (Date.now() - (lastTrip.get(player.id) ?? 0) < 5000) return; // one trip, however often they press F + const id = Sanctuary.destination(player); + if (id && Sanctuary.doorAccess(player) && Sanctuary.enter(player, id)) lastTrip.set(player.id, Date.now()); +}); + +Events.on("playerDisconnect", (player) => lastTrip.delete(player.id)); +``` + +### Keeping players out + +The door is not a lock. Fast travel to the Room of Requirement ignores door access and native entry, and the door itself is drawn by each player's game. What keeps a player out of a room is that the server only sends a room to players whose destination it is, who are entering it or who are in it. To bar a player, clear their destination as well. ## Who is where @@ -81,9 +136,10 @@ The server only knows a player is inside once their game has finished loading th Each room is its own [virtual world](/guides/core-concepts/virtual-worlds/), combined with the player's world number. Players in the same room with the same world number see and hear each other, and nobody in another room. The global world `4294967295` still sees into every room. -Entities the server creates are not tied to a map, so you cannot place an NPC or an object in one particular room. +`setVirtualWorld` places an entity in a map's world, never in one particular room. An NPC or beast created with a `preferredOwnerId` starts in that player's world and map, and so in their room. ## Where to go next - [Rooms](/guides/room-of-requirement/rooms/) for loading, decorating and saving rooms. - [Virtual worlds](/guides/core-concepts/virtual-worlds/) for how worlds and maps combine. +- [Interaction prompts](/guides/user-interface/hud-and-nametags/#interaction-prompts) for the prompt a door script shows. diff --git a/guides/Room of Requirement/rooms.md b/guides/Room of Requirement/rooms.md index 5c30c2b..ef64c88 100644 --- a/guides/Room of Requirement/rooms.md +++ b/guides/Room of Requirement/rooms.md @@ -7,7 +7,7 @@ sidebar: The Room of Requirement holds as many rooms as the server loads. A room is a layout, the objects players have conjured and changed there, stored under an id your script chooses. The `Sanctuary` global on the server loads rooms, decides who may decorate them and who goes into which. -Nobody owns a room unless your script says so. "Every player has a room of their own" is a few lines of script: load a room for each player as they join, make it where their door leads, and let them edit it. +Nobody owns a room unless your script says so. "Every player has a room of their own" is a few lines of script: load a room for each player as they join, make it where their door leads, and let them edit it. The castle door itself takes nobody in until a script [lets players in](/guides/room-of-requirement/doors-and-visits/#letting-players-in). ## Loading a room diff --git a/guides/User interface/avatar-preview.md b/guides/User interface/avatar-preview.md new file mode 100644 index 0000000..fcdba53 --- /dev/null +++ b/guides/User interface/avatar-preview.md @@ -0,0 +1,192 @@ +--- +title: Avatar preview +description: Show the player their own live figure inside a web page, for a gear pane or a wardrobe, framed into a rect the page reports, with clips to play and a backdrop behind it. +sidebar: + order: 605 +--- + +An avatar preview shows the player their own character inside your interface, such as the empty half of a gear pane. The figure is live: it wears their current look and plays an idle or the clips you choose. Only that player sees it. Client scripts drive it with the `AvatarPreview` global, and a [web view](/guides/user-interface/web-views/) tells it where to stand. + +## How it works + +The figure is a copy of the player's character standing in the world a couple of metres in front of the camera, placed and sized so it lands inside a rect on screen. A flat backdrop stands behind it, covering the rect. The page leaves that rect transparent, and the game shows through it. + +Each client has one figure. A new preview replaces the last, whichever resource made it, and the emote preview, `LocalPlayer.emotePreview({ show: true })`, takes the figure over. + +## Creating a preview + +`AvatarPreview.create(options)` starts a preview and returns its id, which the other calls take. It returns 0 when it refuses the options, and `AvatarPreview.state(0).status` then says why. `anchor` is the only option it needs. + +This gear pane creates the preview hidden, and shows it once the page reports where its hole is: + +```jsx +// client/main.js +const GEAR_URL = "fw://resources/wardrobe/ui/gear.html"; +let gearView = -1; +let preview = 0; + +function openGear() { + const view = Web.createView(GEAR_URL, { focus: true }); + gearView = view; + preview = AvatarPreview.create({ + anchor: { view, rect: { x: 0, y: 0, w: 0, h: 0, visible: false } }, + backdrop: { color: "#1b1712", gradient: "#0d0b08" }, + }); + if (!preview) console.log(`No avatar preview: ${AvatarPreview.state(0).status}`); + + Web.on(view, "gear:hole", (hole) => { + const rect = { x: hole.x, y: hole.y, w: hole.w, h: hole.h, visible: hole.visible }; + AvatarPreview.update(preview, { anchor: { view, rect } }); + }); + Web.on(view, "gear:close", closeGear); +} + +function closeGear() { + AvatarPreview.destroy(preview); + Web.destroyView(gearView); + preview = 0; + gearView = -1; +} +``` + +| Option | Meaning | +| --- | --- | +| `anchor` | The rect to frame the figure into. `{ view, rect: { x, y, w, h, visible } }` is in CSS pixels relative to a web view, as its page measures it. `{ screen: { x, y, w, h } }` is in pixels relative to the screen. | +| `camera` | `framing`, how much of the figure fills the rect's height: `"full"` by default, `"body"` or `"head"`. `yaw`, degrees added to the figure facing the camera: -15 by default, a slight turn. | +| `backdrop` | `color`, and `gradient` for a second colour it blends to at the bottom. Colours are `"#rrggbb"` or linear `[r, g, b]` components. `"none"` leaves the backdrop out. `{ color: "#16181e" }` by default. | +| `light` | The figure's own studio lights: `rig` is `"studio"` by default, or `"none"` to leave it to the world's light. `scale` sets their strength, 1 by default. | +| `world` | `scale`, the figure's size from 0.05 to 10, which sets how near the camera it stands: 0.6 by default. `depth`, centimetres from the figure to the backdrop: 40 by default. `margin`, pixels the backdrop runs past the rect: 8 by default. `isolate`: see [Lighting](#lighting). | + +`subject` can only be `"self"`, the default. `backend`, `quality`, `camera.fov` and `camera.pitch` belong to another way of drawing the figure that is not built yet, and the preview ignores them. `light.rig: "fieldguide"` lights like `"studio"` for now. + +`AvatarPreview.update(id, options)` merges some options into the preview's. When a field is malformed it applies none of them, returns `false`, and `state(id).status` names the field. It also returns `false` once the preview has ended. + +### Hiding and ending + +`visible: false` in the anchor's rect hides the figure but keeps it ready, so it comes back at once. A preview anchored to a view also hides while that view is off screen. + +A preview ends when: + +- `AvatarPreview.destroy(id)` removes it, giving back any view settings it held. +- Another `create`, or the emote preview, takes the figure. +- The player loads another map. Create it again once they arrive. +- Its view is destroyed, as it is when your resource stops. + +A preview anchored to the screen outlives your resource, so destroy it as your resource stops. Once a preview has ended, `state(id).ok` is `false` and `status` says why. + +## The page's half + +The page marks the rect with an element it leaves transparent, and tells your script where that element is whenever it changes. `getBoundingClientRect()` reports CSS pixels relative to the view, which is what the anchor takes. + +- Nothing opaque may sit behind the hole, the page's own background included. +- Paint the pane around the hole yourself. The hole's own spread shadow does it: `0 0 0 100vmax` in the pane's colour fills everything around the hole, and `overflow: hidden` on the pane stops the fill at its edges. +- Whatever the page paints over the hole, such as a border or a label, covers the figure. Keep a 2 to 4 px opaque frame over the hole's edge: the backdrop runs `world.margin` pixels past it, under the frame. + +The page's body holds the pane, with the hole inside it: + +```html + +
+
Gear Close
+
+
+``` + +```css +/* ui/gear.css */ +html, body { margin: 0; height: 100%; background: transparent; color: #eadfc8; } +.pane { position: absolute; top: 10vh; right: 4vw; width: 40vw; height: 80vh; overflow: hidden; isolation: isolate; } +.pane > :not(#hole) { position: relative; z-index: 1; } +#hole { position: absolute; top: 48px; right: 16px; bottom: 16px; width: 55%; box-shadow: inset 0 0 0 3px #5a4a2e, 0 0 0 100vmax #15120e; } +``` + +The page's script reports the hole when the page loads, when the window resizes, and when your script asks: + +```js +// ui/gear.js, running in the page +const hole = document.getElementById("hole"); +let sent = ""; + +function reportHole(force) { + const box = hole.getBoundingClientRect(); + const json = JSON.stringify({ + x: Math.round(box.left), + y: Math.round(box.top), + w: Math.round(box.width), + h: Math.round(box.height), + visible: box.width > 0 && box.height > 0, + }); + if (json === sent && !force) return; + sent = json; + callEvent("gear:hole", json); +} + +window.addEventListener("resize", () => reportHole(false)); +window.addEventListener("gear:refresh", () => reportHole(true)); +document.getElementById("close").addEventListener("click", (event) => { + event.preventDefault(); + callEvent("gear:close", null); +}); +reportHole(true); +``` + +Report again after anything else that moves or resizes the hole, such as a tab that changes the layout; a `ResizeObserver` on the hole catches changes to its size. If your script starts listening after the page has loaded, send the page `gear:refresh` with `Web.emit` for a fresh report. + +The game draws the figure a frame or two behind the page, so a hole that moves, such as one in a panel sliding in, leaves the figure trailing. Do not animate the hole: report `visible: false` while it moves, and its place once it settles. + +## Clips and framing + +The figure starts on a looping idle. `AvatarPreview.play(id, clip, loop)` plays one of the game's animation clips on it by its object path, `/Game//.`, looping unless `loop` is `false`. `""` goes back to the idle. Abilities do not play on the figure, only clips. + +```js +// client/main.js +const CHEER = "/Game/Animation/Human/Adult/Adult_F/AdF_BM_Idle_Positive_Cheer_v02_anm.AdF_BM_Idle_Positive_Cheer_v02_anm"; + +function showOff() { + AvatarPreview.play(preview, CHEER); + AvatarPreview.update(preview, { camera: { framing: "body" } }); +} + +function settle() { + AvatarPreview.play(preview, ""); + AvatarPreview.update(preview, { camera: { framing: "full" } }); +} +``` + +A clip that does not load leaves a note in `status`. `state(id).debug.animating` is `false` while the figure stands in its reference pose instead of playing a clip. + +## Matching your page + +The figure and the backdrop stand in the world, so the game treats them like the rest of it: + +- The backdrop does not match a CSS colour exactly. The game's exposure, tone curve and time-of-day grading apply to it like any other pixel of the world. In daylight a dark colour reads near black, and at night brighter. Frame the hole so the seam does not matter, or pick a family of colours rather than one exact shade. +- The figure is not cut to the rect. Where it reaches past the hole, only the page's opaque parts hide it, so keep it inside with `camera.framing`. +- Anything in the world nearer the camera than the backdrop draws over it. The backdrop stands about 2.5 m from the camera at the default size. A smaller `world.scale` brings the figure and the backdrop nearer, so less gets in front. + +## Lighting + +The studio lights take the sun's place on the figure, but the sky's light and the game's ambient light still reach it, so it shifts a little with the hour. + +`world.isolate: true` takes fog, sky light, ambient light and reflections out of the whole view and fixes its exposure, so the studio lights alone light the figure. Because it changes every pixel of the world, it only takes effect while the rect covers at least 75% of the screen, as a full-screen wardrobe does. Destroying the preview puts the view back. + +## Reading a preview + +`AvatarPreview.state(id)` reports what a preview is showing now: + +| Field | Meaning | +| --- | --- | +| `ok` | Whether the preview is live | +| `ready` | Whether the figure has spawned. It takes a few frames, and needs the player's character in the world. | +| `visible` | Whether the figure is on screen | +| `rect` | Where the anchor is on screen, in pixels; `null` while hidden | +| `backdrop` | Whether the backdrop is up | +| `hold` | `"isolate"` while `world.isolate` is in effect, otherwise `"none"` | +| `status` | The last failure or note, cleared once read | + +`backend`, `isolation`, `worldLitMeshes` and `debug` are diagnostics. + +## Where to go next + +- [Web views](/guides/user-interface/web-views/) for shipping the page and passing messages between it and your script. +- [Keys and menus](/guides/user-interface/keys-and-menus/) for opening the pane from a key and keeping the game's own menus shut. +- [Appearance](/guides/players/appearance/) for the look the figure wears. diff --git a/guides/User interface/keys-and-menus.md b/guides/User interface/keys-and-menus.md index 31f52ae..cd0be7d 100644 --- a/guides/User interface/keys-and-menus.md +++ b/guides/User interface/keys-and-menus.md @@ -44,6 +44,26 @@ A binding belongs to the resource that made it and goes when that resource stops A focused web view locks the controls and shows the cursor by itself while it has focus, so there is no need to lock them around `Web.focusView`. +To stop the character without showing the cursor, as in a cutscene, use `LocalPlayer.stopPlayerInput()`. Movement, casting and the other controls stop, while the camera still turns. `LocalPlayer.restorePlayerInput()` hands them back. Each resource holds input once, however often it calls `stopPlayerInput`, and input stays stopped while anything holds it, another resource or a server [walk](/guides/players/walking-players/#input-during-a-walk) included. `restorePlayerInput` gives back only your own hold, and returns `true` if input runs again. A hold carries over respawns, and goes when your resource stops or reloads and when the player disconnects. + +### Jump and sprint + +`LocalPlayer.setJumpEnabled(false)` makes the jump button do nothing, and `LocalPlayer.setSprintEnabled(false)` the sprint button. A sprint already running stops at once, and a jump already in the air lands normally. Both use the game's own locks, the ones its scripted walks use, so giving yours back never frees a jump or sprint the game has locked itself. + +Each resource holds each one once, however often it calls, and the button stays off while any resource holds it. Passing `true` gives back only your own hold. Holds carry over respawns and travel, and go when the resource stops or reloads and when the player disconnects. + +`LocalPlayer.isJumpEnabled()` and `LocalPlayer.isSprintEnabled()` return `false` while any resource holds them off. `LocalPlayer.jumpState()` and `LocalPlayer.sprintState()` also list the resources holding them in `holders`, and say in `locked` whether the game's lock is on right now, whoever turned it on. + +```js +// client/main.js +// No sprinting or jumping while the stamina bar is empty. +setInterval(() => { + const tired = stamina() <= 0; + LocalPlayer.setSprintEnabled(!tired); + LocalPlayer.setJumpEnabled(!tired); +}, 100); +``` + ## The game's own menus Each of these switches keeps one of the game's menus from opening, so a resource can put its own interface on the keys behind it: diff --git a/guides/User interface/map-markers.md b/guides/User interface/map-markers.md index e7185ee..33594e0 100644 --- a/guides/User interface/map-markers.md +++ b/guides/User interface/map-markers.md @@ -1,6 +1,6 @@ --- title: Map markers -description: Put markers, search circles and gold routes on the map and minimap, choose which players get a blip and in what colour, and read what the minimap shows. +description: Put markers, search circles and gold routes on the map and minimap, read the player's own waypoint, choose which players get a blip and in what colour, and read what the minimap shows. sidebar: order: 620 --- @@ -76,6 +76,43 @@ Blips.setRoute("hunt:area", "Search for the clue"); `Blips.clearRoute()` ends the route, and removing its blip ends it too. `Blips.route()` returns the key or network id the route leads to, or `null`. +## The player's waypoint + +Players drop a waypoint of their own on the game's map. It is theirs, not a blip, so the calls above never change it, but `Blips.waypoint()` reads it, or returns `null` when they have none: + +| Field | Meaning | +| --- | --- | +| `x`, `y`, `z` | Where it is, in world centimetres | +| `target` | The beacon name of the map icon it was dropped on, such as `"FT_HW_GreatHall"`; `null` on open ground | + +Dropped on an icon, the waypoint sits exactly on it. On open ground, `z` is the height of the map's model under the cursor: close to the terrain, but not on it. Find [the ground under it](/guides/world/line-traces/#the-ground-under-a-point) before you stand someone there. + +The `waypointChanged` event fires with the new waypoint and the previous one each time the player places, moves or removes theirs, and a removal reports `null`. It only reports changes, so read `Blips.waypoint()` as your script starts for one placed before. This party shares each member's waypoint with the others: + +```js +// client/main.js +Events.on("waypointChanged", (waypoint) => Events.emitServer("party:waypoint", waypoint)); + +Events.on("party:waypoint", (payload) => { + const key = `party:waypoint:${payload.playerId}`; + const at = payload.waypoint; + if (at) Blips.setMarker(key, at.x, at.y, at.z, undefined, payload.name); + else Blips.remove(key); +}); +``` + +```js +// server/main.js +Events.onClient("party:waypoint", (player, waypoint) => { + const valid = waypoint && [waypoint.x, waypoint.y, waypoint.z].every(Number.isFinite); + const at = valid ? { x: waypoint.x, y: waypoint.y, z: waypoint.z } : null; + const payload = JSON.stringify({ playerId: player.id, name: player.nickname, waypoint: at }); + for (const member of partyMembers(player)) { + if (member !== player) member.emit("party:waypoint", payload); + } +}); +``` + ## Player blips Every other player has a blip by default. To show only some of them, turn that off and name the players who keep theirs: diff --git a/guides/World/hogwarts-express.md b/guides/World/hogwarts-express.md new file mode 100644 index 0000000..d772988 --- /dev/null +++ b/guides/World/hogwarts-express.md @@ -0,0 +1,117 @@ +--- +title: Hogwarts Express +description: Run the Hogwarts Express along its track from the server, stop it at Hogsmeade station and send it on, and follow each run through events. +sidebar: + order: 535 +--- + +The Hogwarts Express runs on one track through Overland, past Hogsmeade station. On a HogwartsMP server the game's own timetable is off on every player's game, so the train only comes when a script runs it. `World.hogwartsExpress` runs it from the server, and every player sees it at the same spot on the track, players who arrive mid-run included. + +## Positions on the track + +A position is `t`, from 0 to 1 along the track. At 0 the whole train stands just on the track, south of Hogsmeade. At 1 it stands at the far end. `length` is the metres from 0 to 1, about 2.9 km, so a difference in `t` times `length` is a distance. + +`stops` lists named spots as `{ id, t }`. There is one, `"hogsmeade"`, which stops the train along the station's platform. Every call that takes a position takes a `t` or a stop's id. + +The train runs forward only. It cannot move to a spot behind it; spawn it there again instead. + +## Running the train + +```js +// server/main.js +const express = World.hogwartsExpress; + +express.spawn(0); +express.moveTo("hogsmeade", { speed: 25 }); +``` + +| Call | Does | +| --- | --- | +| `spawn(at)` | Stands the train at `at`, 0 by default, replacing one already out | +| `moveTo(at, options)` | Runs the train from wherever it is, standing or moving, and brakes it to a stop on `at`. It replaces a move or departure under way | +| `depart(options)` | Runs the train off the far end at its cruise speed. It is removed there | +| `halt(options)` | Brakes a moving train to a stop wherever that lands, never past the spot a `moveTo` was heading for | +| `despawn()` | Removes the train at once | + +Each returns `false`, without throwing, when it is refused: there is no train, the spot is behind it, a `t` is outside 0 to 1 or a stop's id is unknown, or an option is out of range. `halt` is also refused for a train that is not moving. Once a departing train passes the end of the track, `moveTo`, `depart` and `halt` refuse it. + +`moveTo` and `depart` take the same options: + +| Option | Meaning | +| --- | --- | +| `speed` | Cruise speed in metres per second: 20 by default, the game's own, up to 60 | +| `accel` | How hard it pulls away, in m/s²: 0.5 by default, up to 10 | +| `decel` | How hard it brakes, in m/s²: 0.7 by default, up to 10 | + +A spot too close to stop on at `decel` brakes harder, so the train still stops on it. On a departure, `decel` only slows a train that is faster than `speed` down to it. `halt` takes `decel` alone, and `halt({ decel: Infinity })` stops the train dead. Speeds and times are real, whatever the [clock](/guides/world/time-and-weather/#the-clock-and-calendar)'s time scale. + +### Where the train is + +`get()` reads the train now: + +| Field | Meaning | +| --- | --- | +| `spawned` | Whether a train is out | +| `moving`, `departing` | Whether it is running, and whether that run takes it off the end | +| `t`, `speed` | Where it is and how fast it goes, in m/s, while it is out | +| `target` | The `t` it is braking onto, or `null` | +| `seconds` | Seconds until it stops, or until a departure leaves the track; `null` while it stands | + +## Train events + +| Event | Handler arguments | When it fires | +| --- | --- | --- | +| `trainSpawned` | `(t)` | `spawn` stood the train at `t` | +| `trainStopped` | `(t)` | The train braked to a stop at `t`, at the end of a `moveTo` or a `halt` | +| `trainEnded` | `(reason)` | The train is gone: `"end"` when a departure left the track, `"despawned"` after `despawn()` or a `spawn()` that replaced it | + +After a `moveTo` to a stop, `trainStopped` passes that stop's `t` from `stops`. A `t` you passed as a number can come back very slightly rounded, so compare it with a small margin rather than `===`. + +```js +// server/main.js +const express = World.hogwartsExpress; +const hogsmeade = express.stops.find((stop) => stop.id === "hogsmeade"); + +function runToHogsmeade() { + if (express.get().spawned) return false; + return express.spawn(0) && express.moveTo("hogsmeade"); +} + +Events.on("trainStopped", (t) => { + if (!hogsmeade || Math.abs(t - hogsmeade.t) > 0.001) return; + Chat.sendToAll("The Hogwarts Express is at Hogsmeade. It leaves in two minutes."); + setTimeout(() => express.depart(), 120000); +}); + +Events.on("trainEnded", (reason) => { + if (reason === "end") Chat.sendToAll("The Hogwarts Express has gone."); +}); +``` + +There is one train for the whole server. Every resource can move it and receives its events, so let one resource run it. + +## Who sees it + +- Every player on Overland sees the train, in every virtual world. It is not an entity you place in a world. +- A player in a dungeon or another map does not see it. Back on Overland, they see it where it is by then, as a player who joins mid-run does. +- The train belongs to the server, not to a resource. Stopping the resource that ran it leaves the train as it is, standing or still running. + +## With Foundations + +HMP Foundations' [`hmp-express`](https://github.com/hogwarts-mp/foundations/tree/main/resources/hmp-express) puts a timetable back. At each departure, in in-game time on the server's clock, it stands the train at the start of the track, runs it to each stop, waits there and sends it off the far end. Its `data/hmp-express.json` holds: + +| Key | Meaning | +| --- | --- | +| `enabled` | `false` leaves the train to your own script | +| `departures` | Each departure's `time`, as `"HH:MM"`, and the `days` of the week it runs on, every day when left out. By default the game's own: 10:00 on Monday, Wednesday and Friday, and 21:00 on Saturday | +| `stops` | Where the train stands on the way, in track order: `at`, a stop's id or a `t`, and `dwellSeconds`, 120 by default. `[]`, the default, drives straight through, as the game does | +| `speed`, `accel`, `decel` | As for `moveTo` | +| `year` | The year the day of the week is taken from while the server keeps the game's own year: 1891 by default | + +A departure due while the train is still out is skipped. Other resources can drive the train through its `train` export, which takes that train off the timetable until it is gone. To run the train from your own script instead, set `enabled` to `false`. + +## Where to go next + +- [Time and weather](/guides/world/time-and-weather/) for the server's clock and calendar, which a timetable runs on. +- [Virtual worlds](/guides/core-concepts/virtual-worlds/#what-ignores-worlds) for the other things every player sees whatever their world. +- [Travel between places](/guides/players/travel/) for maps, and moving players between them. diff --git a/guides/World/line-traces.md b/guides/World/line-traces.md index deab2f9..7b4e91b 100644 --- a/guides/World/line-traces.md +++ b/guides/World/line-traces.md @@ -20,6 +20,8 @@ if (from) { `start` and `end` are world positions in centimetres. A trace returns the first hit, or `null` when the line hits nothing, including when it runs into an area the player's game has not loaded. A malformed argument throws. +To see where a trace runs while you work on it, draw it with [Shapes](/guides/world/shapes/#debugging-a-line-trace). + ## What it hits By default a trace stops where the game's visibility checks stop: the ground, buildings and objects, and players' bodies, where it also names the bone it hit. The options change that: diff --git a/guides/World/loot.md b/guides/World/loot.md index 3d93239..1c47d82 100644 --- a/guides/World/loot.md +++ b/guides/World/loot.md @@ -24,6 +24,7 @@ const chestId = Lootables.place("chest", x, y, z, 90, { | `locked` | A locked lootable offers nothing to take | | `mode` | `"shared"` (the default): a take empties it for everyone. `"perPlayer"`: a take empties it only for the player who took it. | | `model` | The Blueprint to show. It defaults to a Hogwarts chest, a small moonstone deposit, or the foragable plant that gives the item. | +| `virtualWorld`, `map` | The [virtual world](/guides/core-concepts/virtual-worlds/#loot-zones-and-sounds) to place it in, and the map that world is in, by its `areaId`. World 0 of Overland by default. | Moonstone deposits and foragables hold plain items only, and a foragable holds a single stack. A moonstone takes one of the `BP_Moonstone_S`, `_M` or `_L` Blueprints, and a foragable one of the game's five foragable Blueprints. An empty or locked moonstone shows as mined out, and an empty or locked foragable as picked. diff --git a/guides/World/objects-and-effects.md b/guides/World/objects-and-effects.md index c38cb35..3d251f0 100644 --- a/guides/World/objects-and-effects.md +++ b/guides/World/objects-and-effects.md @@ -1,6 +1,6 @@ --- title: Objects and effects -description: Place props from the game's meshes and Blueprints, move them, and add visual effects that stay put or follow an entity, keyed so you can replace or remove them later. +description: Place props from the game's meshes and Blueprints, move, animate and time them, and add visual effects that stay put or follow an entity, keyed so you can replace or remove them later. sidebar: order: 510 --- @@ -18,7 +18,7 @@ WorldObject.create("market-barrel-1", "/Game/Environment/BanditCamps/Interactabl }); ``` -`WorldObject.create(key, model, options)` takes the model as a mesh's asset path, `/Game//.`, a Blueprint's class path, `/Game//BP_Name.BP_Name_C`, or a numeric id from the Mod's catalog of world-object meshes, and throws for anything else. It returns the object, or `undefined` if it could not be created. +`WorldObject.create(key, model, options)` takes the model as a static or skeletal mesh's asset path, `/Game//.`, a Blueprint's class path, `/Game//BP_Name.BP_Name_C`, or a numeric id from the Mod's catalog of world-object meshes, and throws for anything else. It returns the object, or `undefined` if it could not be created. | Option | Meaning | | --- | --- | @@ -32,9 +32,16 @@ WorldObject.create("market-barrel-1", "/Game/Environment/BanditCamps/Interactabl | `fx`, `fxSocket` | An effect's path to attach to the object as it spawns, and the socket on its mesh to attach it to | | `physics`, `massKg` | Makes a mesh object a [physics object](/guides/world/physics-objects/) that falls, rolls and can be thrown, and its mass | | `simulated` | For a Blueprint, lets one player's game run it for everyone; see [Blueprint objects](#blueprint-objects) | +| `streamRange` | How close a player must be to receive the object, in centimetres: 50000 (500 m) by default, from 100 to 200000 | +| `drawDistance` | How far away players who have the object still draw it, in centimetres; 0 draws it at any distance. Without it, a mesh is drawn at any distance and a Blueprint keeps the game's own distances | +| `animation`, `material` | Its [look](#animation-and-material) | +| `motion` | Sends it off [by itself](#objects-that-move-by-themselves) | +| `ttl`, `startFx`, `endFx`, `shrinkOut` | Its [lifetime](#timed-objects) | The prompt is only text; handling the interaction it invites is up to your resource. `WorldObject.destroy(key)` removes an object by its key, `object.destroy()` removes one through its handle, and `WorldObjectManager.getAll()` lists every live object. `object.modelPath` reads back the model it shows, whichever form you created it with. +`object.streamRange` and `object.drawDistance` change those two later, and a negative `drawDistance` goes back to the default. Lower `streamRange` for small props nobody notices from afar, so distant players are not sent them, and raise it for a landmark. + ### Moving objects Set `object.position` to move an object. It glides to the new place on every player's screen: @@ -51,7 +58,51 @@ setInterval(() => { ### Blueprint objects -A Blueprint object runs the game's own logic for that Blueprint. Placed normally, every player's game runs its own copy, so anything that moves by itself moves differently for each player. With `simulated: true`, the game of the nearest player runs it and everyone else follows. The game's race ring, `/Game/Gameplay/BroomRaces/Blueprints/BP_RaceRing.BP_RaceRing_C`, and loose snitch, `/Game/Levels/WorldEvents/Blueprints/LooseSnitch/BP_Loose_Snitch.BP_Loose_Snitch_C`, are known to work; [Quidditch](/guides/quidditch/running-a-match/) builds a match from them. Other Blueprints spawn, but may not behave. +A Blueprint object runs the game's own logic for that Blueprint. Placed normally, every player's game runs its own copy, so anything that moves by itself moves differently for each player. With `simulated: true`, the game of the nearest player runs it and everyone else follows, and it changes hands the way a [physics object](/guides/world/physics-objects/#who-simulates-it) does. The game's race ring, `/Game/Gameplay/BroomRaces/Blueprints/BP_RaceRing.BP_RaceRing_C`, and loose snitch, `/Game/Levels/WorldEvents/Blueprints/LooseSnitch/BP_Loose_Snitch.BP_Loose_Snitch_C`, are known to work; [Quidditch](/guides/quidditch/running-a-match/) builds a match from them. Other Blueprints spawn, but may not behave. + +### Animation and material + +`animation` loops an animation on a skeletal mesh, such as one of the game's creatures. It has to be made for that mesh's skeleton: one made for another skeleton does not play. `material` puts one material on every slot of the mesh; without it, the mesh keeps its own. Both are asset paths, `/Game//.`, and both apply only to meshes: a Blueprint keeps its own look. + +### Timed objects + +`ttl` has the server destroy the object after that many seconds, up to 3600, as it does an effect. `shrinkOut` shrinks it away over its last seconds, and can be no longer than the `ttl`. `startFx` is an effect each player's game plays where the object appears, for players who receive it within half a second of its creation. `endFx` is one played where each player's copy is when the time runs out; destroying the object before then plays none. + +### Objects that move by themselves + +`motion: { speed, heading, followGround }` sends an object off in a straight line from where it was created, at `speed` centimetres a second along `heading`, a world yaw in degrees that defaults to the object's `yaw`. With `followGround`, it keeps to the ground beneath it instead of its starting height. It does not turn, so it faces its `yaw` all the way. The game's creature meshes are turned 90 degrees from their yaw, so create one with `yaw: heading - 90` to have it run head first. + +Each player's game runs the motion itself, so nothing is sent while the object moves, and a player who comes into range mid-run sees it where everyone else does. The server's `object.position` stays at the starting point, so a [zone](/guides/encounters/zones/) sees the object there too. It keeps going until its `ttl` runs out, and setting `object.position` or `object.rotation` ends the run and leaves it where you put it. `create` throws for `motion` together with `physics` or `simulated`. + +This summons a glowing stag that gallops off the way a player faces, then vanishes: + +```js +// server/main.js +const STAG = "/Game/RiggedObjects/Characters/Creatures/Stag/SK_DeerStag.SK_DeerStag"; +const GALLOP = "/Game/Animation/Creatures/Deer/Deer_BM_Gallop_anm.Deer_BM_Gallop_anm"; +const GLOW = "/Game/VFX/MasterMaterials/MaterialInstance/VFX_MI_CIN_Glow_Patronus.VFX_MI_CIN_Glow_Patronus"; +const PUFF = "/Game/VFX/UI/Patronus/VFX_NS_Patronus_Spawn.VFX_NS_Patronus_Spawn"; +let summons = 0; + +Events.on("chatCommand", (player, message, command) => { + if (command.toLowerCase() !== "patronus") return; + const heading = player.location()?.yaw ?? 0; + const { x, y, z } = player.position; + const stag = WorldObject.create(`patronus:${++summons}`, STAG, { + x, y, z, + yaw: heading - 90, + collision: false, + animation: GALLOP, + material: GLOW, + motion: { speed: 900, heading, followGround: true }, + ttl: 3, + shrinkOut: 0.4, + startFx: PUFF, + endFx: PUFF, + }); + stag?.setVirtualWorld(player.virtualWorld, player.location()?.areaId); +}); +``` ## Effects @@ -76,13 +127,13 @@ Fx.create("spawn-flash", flashPath, { x, y, z, scale: 2, ttl: 5 }); function lightTorch(player) { const torch = Fx.create(`torch:${player.id}`, campfire, { scale: 0.3 }); if (!torch) return undefined; - torch.setVirtualWorld(player.virtualWorld); + torch.setVirtualWorld(player.virtualWorld, player.location()?.areaId); torch.attachTo(player, { x: 30, y: 25, z: 70 }); return torch; } ``` -An attached effect does not take its entity's virtual world, so set it to match. +An attached effect does not take its entity's virtual world or map, so set them to match. `effect.setColor(name, color)` sets one of a Niagara effect's colour parameters, by the parameter's name, for everyone. Components run from 0 to 1, and values above 1 glow. An effect can carry up to four colours, and Cascade effects ignore them. @@ -90,7 +141,7 @@ An attached effect does not take its entity's virtual world, so set it to match. Objects and effects your resource creates are destroyed when it stops, along with its other entities. They are not saved: a resource that keeps placed props across restarts stores their keys, models and positions itself and creates them again when it starts. -Like other entities they start in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0; call `setVirtualWorld` on one to show it to players in another world. +Like other entities they start in [virtual world](/guides/core-concepts/virtual-worlds/#placing-entities) 0 of Overland; call `setVirtualWorld(world, map)` on one to show it to players in another world or map. ## Where to go next diff --git a/guides/World/physics-objects.md b/guides/World/physics-objects.md index 8a558f8..84c5e86 100644 --- a/guides/World/physics-objects.md +++ b/guides/World/physics-objects.md @@ -24,7 +24,7 @@ Players walk through physics objects and never push them, so anything that moves ## Who simulates it -The nearest player within 150 m, measured along the ground, in the object's virtual world, simulates it. They keep it until they are more than 250 m away or someone else is clearly nearer. `object.simulatorId` is that player's id. With nobody near, the object rests where it was last reported until someone comes. +The nearest player within 150 m, measured along the ground, in the object's virtual world, simulates it. They keep it until they have been more than 250 m away, or someone else clearly nearer, for 3 seconds, so a ball flying past another player stays with whoever threw it. `object.simulatorId` is that player's id. With nobody near, the object rests where it was last reported until someone comes. The simulating player's game reports where the object is, and the server takes its word. Anything you decide from a physics object's position, such as a goal, trusts that player. diff --git a/guides/World/shapes.md b/guides/World/shapes.md new file mode 100644 index 0000000..3550d57 --- /dev/null +++ b/guides/World/shapes.md @@ -0,0 +1,153 @@ +--- +title: Shapes +description: Draw rings, spheres, boxes, cylinders, polygons and lines in the world from a client script, to show areas, mark spots and see what your code is doing. +sidebar: + order: 545 +--- + +`Shapes` draws simple shapes into the world on one player's screen: rings, spheres, boxes, cylinders, polygons and lines. Use them to show where an area's edges are, mark a spot, preview where something will go, or see a line trace while you work on it. + +A shape is drawn by the client script that creates it, and only that player sees it. To show one to several players, the server tells each of their clients what to draw. Shapes are part of the scene rather than an overlay, so walls and the ground hide them. They have no collision, and they are unlit, so they keep their colour at night. + +## Drawing a shape + +```js +// client/main.js +Shapes.create("market:edge", { + type: "ring", + center: { x: 12000, y: -4500, z: 200 }, + radius: 3000, + ground: true, + color: { r: 1, g: 0.8, b: 0.2 }, + fill: { r: 1, g: 0.8, b: 0.2, a: 0.15 }, +}); +``` + +`Shapes.create(key, shape)` draws a shape under a key of up to 128 characters, or replaces the one there, from the next frame. Positions and sizes are in world centimetres. A malformed shape throws a `TypeError`, and so does one with both `color` and `fill` set to `null`, which would draw nothing. A player's game holds at most 1024 shapes from all resources together, and one more throws an `Error`. + +Keys belong to the resource that creates them, so two resources can use the same key without touching each other's shapes. A resource's shapes are destroyed when it stops. + +## Types + +| `type` | Fields it reads | Outline | `fill` | +| --- | --- | --- | --- | +| `"ring"` | `center`, `radius` | A flat circle | A disc | +| `"sphere"` | `center`, `radius` | Three circles round its centre | The sphere | +| `"box"` | `center`, `size`, `yaw` | Its twelve edges | Its six faces | +| `"cylinder"` | `center` (the middle of its base), `radius`, `height` | Its bottom and top circles | Its wall | +| `"polygon"` | `points`, `minZ`, `maxZ` | Its floor, and when it has height its top and corners | Its floor and walls | +| `"line"` | `from`, `to` | The line | Nothing: colour a line with `color` | + +Faces draw on both sides, so a player inside a filled sphere or cylinder sees it around them. + +## Options + +| Option | Meaning | +| --- | --- | +| `center` | The centre of a ring, sphere or box, and the middle of a cylinder's base: `{ x: 0, y: 0, z: 0 }` by default. A `Vector3` works | +| `radius` | A ring's, sphere's or cylinder's radius | +| `size` | A box's full lengths along its own axes, as `{ x, y, z }`, before `yaw` | +| `yaw` | A box's turn about the vertical, in degrees, from +X toward +Y: 0 by default | +| `height` | How far a cylinder reaches up from its base | +| `points` | A polygon's 3 to 64 corners as `{ x, y }`, in order, with no edges crossing | +| `minZ`, `maxZ` | A polygon's floor and top. Leave out `maxZ` for a flat polygon at `minZ` | +| `from`, `to` | A line's ends, at least 1 cm apart | +| `thickness` | The width of every line drawn, up to 1000: 8 by default | +| `segments` | How many sides a ring's, sphere's or cylinder's circles have, from 3 to 256, so 6 draws a hexagon. `null`, the default, gives about one side per 30 cm of circumference, from 24 to 128 | +| `color` | The outline's colour: opaque white by default. `null` draws no outline | +| `fill` | The colour of the faces inside the outline: `null`, none, by default | +| `visible` | Whether it draws: `true` by default | +| `ground` | Drops it onto the ground below it. See [On the ground](#on-the-ground) | +| `attachTo` | Makes it follow the local player or an entity. See [Following an entity](#following-an-entity) | + +`radius`, `size` and `height` must be more than 0. + +A colour, a `ShapeColor`, is `{ r, g, b, a }`. Its channels are linear, from 0 to 1, and values above 1, up to 10, glow. `a` is its opacity, from 0 to 1 and 1 by default: below 1 the shape is see-through. + +## Changing and removing shapes + +`Shapes.update(key, changes)` changes the fields you pass and keeps the rest. It throws like `create`, and a shape's `type` cannot change: create it again under the same key instead. Moving a shape by its `center` or a line by both its ends, recolouring it and hiding it are cheap. Any change to its form, such as a new `radius` or a polygon's `points`, rebuilds it. + +`Shapes.setVisible(key, visible)` hides or shows a shape without destroying it, and `Shapes.get(key)` returns its fields as they stand, defaults filled in, or `null`. `Shapes.destroy(key)` removes one. `update`, `setVisible` and `destroy` return `false` when no shape has the key. `Shapes.destroyAll()` removes all of the resource's shapes and returns how many there were, and `Shapes.keys()` lists their keys. + +A shape stays when the player changes map, at the same coordinates in the new one. Destroy the shapes that only mean something in the old map. + +## Following an entity + +With `attachTo`, a shape follows the local player, as `"localPlayer"`, or the player, NPC, beast, world object or character with that id, the same id the server uses. Every position in it, from `center` to a polygon's `points` and `minZ`, is then an offset from the entity's position. It follows where the entity is, but does not turn with it. While the entity has no body on this player's game, such as when it is out of range, the shape is hidden. `attachTo: null` makes its positions world positions again. + +```js +// client/main.js +Shapes.create("spell:range", { + type: "ring", + radius: 1500, + attachTo: "localPlayer", + ground: true, + color: { r: 0.3, g: 0.6, b: 1, a: 0.8 }, +}); +``` + +## On the ground + +`ground: true` drops a shape's origin onto the ground below it: the centre of a ring, sphere or box, a cylinder's base, a polygon's floor under its middle, or a line's start. The ground is looked for from 50 cm above that point to 10 m below, and with none there the shape stays at its own height. Bodies do not count, so a ring round a player lies on the floor under them. A grounded shape that follows an entity finds the ground again as it moves, over steps and slopes. + +Only that one point is dropped, and the rest of the shape keeps its form. On a slope, part of a wide ring sinks into the hill, where the ground hides it, and part floats. + +## Showing a zone + +A [zone](/guides/encounters/zones/) cannot be seen. Its shape uses the same fields as a shape here, so the server can send `zone.getShape()` to a client to draw: + +```js +// server/main.js +const arena = Zone.create("arena:floor", { + type: "cylinder", + center: { x: 12000, y: -4500, z: 200 }, + radius: 3000, + height: 1500, +}); + +function showArena(player) { + player.emit("arena:bounds", JSON.stringify(arena.getShape())); +} +``` + +```js +// client/main.js +const RED = { r: 1, g: 0.25, b: 0.2 }; + +Events.on("arena:bounds", (shape) => { + Shapes.create("arena:bounds", { + type: "cylinder", + center: shape.center, + radius: shape.radius, + height: shape.height, + color: RED, + fill: { ...RED, a: 0.08 }, + }); +}); +``` + +## Debugging a line trace + +A [line trace](/guides/world/line-traces/) is easier to get right when you can see it. This helper runs a trace and draws it: red when it hits nothing, green with a dot where it hits. Each call replaces the last drawing. + +```js +// client/main.js +const HIT = { r: 0.2, g: 1, b: 0.3 }; +const MISS = { r: 1, g: 0.2, b: 0.2 }; + +function debugTrace(start, end, options) { + const hit = World.lineTrace(start, end, options); + Shapes.create("debug:trace", { type: "line", from: start, to: end, thickness: 2, color: hit ? HIT : MISS }); + if (hit) Shapes.create("debug:hit", { type: "sphere", center: hit.location, radius: 10, color: null, fill: HIT }); + else Shapes.destroy("debug:hit"); + return hit; +} +``` + +## Where to go next + +- [Zones](/guides/encounters/zones/) for areas the server watches for players coming and going. +- [Line traces](/guides/world/line-traces/) for finding what a line through the world hits. +- [Objects and effects](/guides/world/objects-and-effects/) for props and effects every nearby player sees. +- [Map markers](/guides/user-interface/map-markers/) for marking places on the map instead. diff --git a/guides/overview.md b/guides/overview.md index f9c9e64..40eeb16 100644 --- a/guides/overview.md +++ b/guides/overview.md @@ -30,15 +30,19 @@ Use the navigation to browse the globals available to the selected environment. **Players:** - [Join and spawn](/guides/players/join-and-spawn/) — admitting players, the events of a visit, spawn points, deaths and respawns. +- [Travel between places](/guides/players/travel/) — teleports, fast travel, loading another map, and the places the game builds from several maps. +- [Walking and seating players](/guides/players/walking-players/) — walking players to a point or after another player, seating them, and landing a scripted flight. - [Chat and commands](/guides/players/chat-and-commands/) — sending chat, taking over the chat relay, and slash commands. - [Inventory](/guides/players/inventory/) — the server-owned inventory, atomic changes, saving inventories, and items the game hands out. -- [Appearance](/guides/players/appearance/) — saving and restoring looks, the character creator, clothes, house, size, disguises and props. +- [Appearance](/guides/players/appearance/) — saving and restoring looks, the character creator, clothes, house, size, disguises, animal forms, props and player collision. - [Progression](/guides/players/progression/) — levels and experience, which spells players can cast, talents, and every talent in the game. +- [Custom spells](/guides/players/custom-spells/) — spells of your own built on the game's, their casts, how PvP prices them and modded spells, and lessons of your own. **NPCs and creatures:** - [NPCs](/guides/npcs-and-creatures/npcs/) — spawning enemies, who runs their AI, controlling their health, targets and size, and switching their behaviour off. -- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/) — tame creatures players care for, and stationary townspeople. +- [Moving NPCs and creatures](/guides/npcs-and-creatures/movement/) — paths of waypoints for NPCs, beasts and characters, beasts that follow players, idles, and arrival events. +- [Beasts and characters](/guides/npcs-and-creatures/beasts-and-characters/) — tame creatures players care for, and townspeople, ghosts and other characters. - [Catching beasts](/guides/npcs-and-creatures/catching-beasts/) — the Nab-Sack, refereeing catches, keeping a player's bag across reconnects, and releasing beasts. **PvE:** @@ -66,7 +70,9 @@ Use the navigation to browse the globals available to the selected environment. - [Physics objects](/guides/world/physics-objects/) — objects that fall, roll and fly, pushing and throwing them, and letting players carry them. - [Doors](/guides/world/doors/) — holding the game's doors locked or open for everyone, and deciding who gets in. - [Loot](/guides/world/loot/) — server-owned chests, moonstones and foragables, per-player contents, and switching off the game's own loot. +- [Hogwarts Express](/guides/world/hogwarts-express/) — running the train from the server, stopping it at Hogsmeade, its events, and the Foundations timetable. - [Line traces](/guides/world/line-traces/) — finding what a line through the world hits first, from a client script. +- [Shapes](/guides/world/shapes/) — drawing rings, spheres, boxes, cylinders, polygons and lines in the world from a client script, to show zones or debug traces. **Room of Requirement:** @@ -77,13 +83,14 @@ Use the navigation to browse the globals available to the selected environment. **User interface:** - [Web views](/guides/user-interface/web-views/) — HTML interfaces in the game, and messages between the page and your scripts. +- [Avatar preview](/guides/user-interface/avatar-preview/) — the player's own live figure framed in a web page, for gear panes and wardrobes. - [HUD, prompts and nametags](/guides/user-interface/hud-and-nametags/) — hiding the game's HUD, interaction prompts, and what players' nametags show. -- [Map markers](/guides/user-interface/map-markers/) — markers, search circles and routes on the map, player blips, and the minimap. +- [Map markers](/guides/user-interface/map-markers/) — markers, search circles and routes on the map, the player's own waypoint, player blips, and the minimap. - [Keys and menus](/guides/user-interface/keys-and-menus/) — key bindings, locking the controls, and keeping the game's own menus shut. **Camera and sound:** -- [Camera](/guides/camera-and-sound/camera/) — taking over the view with shots, paths and orbits, fades, and handing it back. +- [Camera](/guides/camera-and-sound/camera/) — taking over the view with shots, paths and orbits, mouse look, fades, and handing it back. - [Audio](/guides/camera-and-sound/audio/) — the game's sounds from the server or a client, soundbanks and music. - [Voice chat](/guides/camera-and-sound/voice-chat/) — who hears whom, voice ranges and tiers, muting, and each player's voice settings.