diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 7c5726a..0000000 --- a/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -.docusaurus \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..17368a5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,45 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + # Review gh actions docs if you want to further define triggers, paths, etc + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on + +permissions: + contents: write + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Build website + run: yarn build + + # Popular action to deploy to GitHub Pages: + # Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Build output to publish to the `gh-pages` branch: + publish_dir: ./build + # The following lines assign commit authorship to the official + # GH-Actions bot for deploys to `gh-pages` branch: + # https://github.com/actions/checkout/issues/13#issuecomment-724415212 + # The GH actions bot is used by default if you didn't specify the two fields. + # You can swap them out with your own user credentials. + user_name: github-actions[bot] + user_email: 41898282+github-actions[bot]@users.noreply.github.com diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 58e2d9b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:16.14.2-alpine - -WORKDIR /app - -COPY package.json . - -RUN yarn - -COPY . . - -RUN yarn build - -EXPOSE 3003 -CMD yarn serve --port 3003 \ No newline at end of file diff --git a/README.md b/README.md index aaba2fa..0c6c2c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Website -This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. +This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. ### Installation diff --git a/blog/2019-05-28-first-blog-post.md b/blog/2019-05-28-first-blog-post.md new file mode 100644 index 0000000..02f3f81 --- /dev/null +++ b/blog/2019-05-28-first-blog-post.md @@ -0,0 +1,12 @@ +--- +slug: first-blog-post +title: First Blog Post +authors: + name: Gao Wei + title: Docusaurus Core Team + url: https://github.com/wgao19 + image_url: https://github.com/wgao19.png +tags: [hola, docusaurus] +--- + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/blog/2019-05-29-long-blog-post.md b/blog/2019-05-29-long-blog-post.md new file mode 100644 index 0000000..26ffb1b --- /dev/null +++ b/blog/2019-05-29-long-blog-post.md @@ -0,0 +1,44 @@ +--- +slug: long-blog-post +title: Long Blog Post +authors: endi +tags: [hello, docusaurus] +--- + +This is the summary of a very long blog post, + +Use a `` comment to limit blog post size in the list view. + + + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/blog/2021-08-01-mdx-blog-post.mdx b/blog/2021-08-01-mdx-blog-post.mdx new file mode 100644 index 0000000..c04ebe3 --- /dev/null +++ b/blog/2021-08-01-mdx-blog-post.mdx @@ -0,0 +1,20 @@ +--- +slug: mdx-blog-post +title: MDX Blog Post +authors: [slorber] +tags: [docusaurus] +--- + +Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/). + +:::tip + +Use the power of React to create interactive blog posts. + +```js + +``` + + + +::: diff --git a/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg b/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg new file mode 100644 index 0000000..11bda09 Binary files /dev/null and b/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg differ diff --git a/blog/2021-08-26-welcome/index.md b/blog/2021-08-26-welcome/index.md new file mode 100644 index 0000000..9455168 --- /dev/null +++ b/blog/2021-08-26-welcome/index.md @@ -0,0 +1,25 @@ +--- +slug: welcome +title: Welcome +authors: [slorber, yangshun] +tags: [facebook, hello, docusaurus] +--- + +[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog). + +Simply add Markdown files (or folders) to the `blog` directory. + +Regular blog authors can be added to `authors.yml`. + +The blog post date can be extracted from filenames, such as: + +- `2019-05-30-welcome.md` +- `2019-05-30-welcome/index.md` + +A blog post folder can be convenient to co-locate blog post images: + +![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg) + +The blog supports tags as well! + +**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config. diff --git a/blog/authors.yml b/blog/authors.yml new file mode 100644 index 0000000..bcb2991 --- /dev/null +++ b/blog/authors.yml @@ -0,0 +1,17 @@ +endi: + name: Endilie Yacop Sucipto + title: Maintainer of Docusaurus + url: https://github.com/endiliey + image_url: https://github.com/endiliey.png + +yangshun: + name: Yangshun Tay + title: Front End Engineer @ Facebook + url: https://github.com/yangshun + image_url: https://github.com/yangshun.png + +slorber: + name: SΓ©bastien Lorber + title: Docusaurus maintainer + url: https://sebastienlorber.com + image_url: https://github.com/slorber.png diff --git a/docs/avatars.md b/docs/avatars.md index abc24d7..a19d4a9 100644 --- a/docs/avatars.md +++ b/docs/avatars.md @@ -14,13 +14,13 @@ To get you started quickly, we offer a library of open source avatars for you to For now this includes 200+ Crypto Avatars from [Vipe](https://vipe.io) that you can equip right from the account menu: -Crypto Avatars in Hyperfy +![](/img/cryptoavatars.png) ## Avatar NFT's All NFT's that you own that have a VRM avatar embedded will be displayed in the account menu for quick access: -NFT Avatars in Hyperfy +![](/img/nft-avatars.png) ## Uploading An Avatar diff --git a/docs/controls.md b/docs/controls.md index 94908e0..aff202e 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -35,7 +35,7 @@ You can explore in either portrait or landscape mode by rotating your device. If your device supports it, a button will be shown to let you enter immersive VR: -Hyperfy VR Button +![](/img/vr.png) Note: if you want to use voice chat, enable it before entering VR! diff --git a/docs/designers/_category_.json b/docs/designers/_category_.json new file mode 100644 index 0000000..86ab0c3 --- /dev/null +++ b/docs/designers/_category_.json @@ -0,0 +1,5 @@ +{ + "position": 120, + "label": "🎨 Designers", + "collapsible": true +} \ No newline at end of file diff --git a/docs/designers/bloom.md b/docs/designers/bloom.md index 86ff54c..a8a07bd 100644 --- a/docs/designers/bloom.md +++ b/docs/designers/bloom.md @@ -6,7 +6,7 @@ sidebar_position: 20 Bloom is an effect that makes an object glow. -Hyperfy Bloom +![](/img/designers-bloom.png) Hyperfy supports "HDR Bloom" which means that any material that emits color into HDR levels will glow. diff --git a/docs/designers/index.md b/docs/designers/index.md deleted file mode 100644 index 7c1d25c..0000000 --- a/docs/designers/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 120 ---- - -# 🎨 Designers diff --git a/docs/developers/_category_.json b/docs/developers/_category_.json new file mode 100644 index 0000000..247f531 --- /dev/null +++ b/docs/developers/_category_.json @@ -0,0 +1,5 @@ +{ + "position": 130, + "label": "πŸ–₯️ Developers", + "collapsible": true +} \ No newline at end of file diff --git a/docs/developers/_quickstart_consolidated.md b/docs/developers/_quickstart_consolidated.md new file mode 100644 index 0000000..853fee4 --- /dev/null +++ b/docs/developers/_quickstart_consolidated.md @@ -0,0 +1,232 @@ +--- +sidebar_position: 20 +--- + +# Quick Start + +To get started you'll need [NodeJS](https://nodejs.org/en) + [Npm/Yarn](https://classic.yarnpkg.com/lang/en/docs/install/). We recommend using NodeJS `v16.14.2` as this is the version we build and test with. If you are on a Windows operating system you may have trouble install/deploying without having [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) (Windows Subsystem for Linux 1 or 2) installed on your machine. A utility like [nvm](https://github.com/nvm-sh/nvm) allows you to quickly switch between different versions of node. + +The easiest way to get started with the SDK is by initializing a new project using `npx`: + +```bash +npx hyperfy create quickstart +``` + +This will create a directory containing the project with a demo app to get you started. + +Navigate into the directory and launch the world: + +```bash +cd quickstart +npm install +npm run dev +``` + +Visit `http://localhost:4000` in your browser. You should now be standing in a virtual world running locally on your machine! + +Hit `Tab` to open the editor. + +Your project comes with a demo app called `Treasure Chest` that you can add to the world. When you walk up to it and click on it, it opens. + +Open `apps/treasure-chest/index.js` and change the `OPEN_CLOSE_SPEED` value from `0.5` to `2` and hit save. Your app will be updated and you'll see that the treasure chest now animates slower when opening/closing. + +To test multiplayer, just open multiple browser tabs. Each tab will automatially be assigned its own guest account. + + +## Structure + +Once you've created a new project it will look something like this: + +``` +my-project/ +β”œβ”€ apps/ +β”‚ β”œβ”€ treasure-chest/ +β”‚ β”‚ β”œβ”€ assets/ models, images, audio, etc +| | |- src/ raw assets eg .blend +β”‚ β”‚ β”œβ”€ app.json app metadata +β”‚ β”‚ β”œβ”€ secrets.json app server-side secrets +β”‚ β”‚ β”œβ”€ index.js script entrypoint +``` + +## Apps + +Each app lives inside its own folder inside the `/apps` directory. They will be built when code is changed and in the browser you can bring them into the world to test from the editor, opened with the `Tab` key. + +Everything added to a world in Hyperfy is an App. It is up to you if you want to create one giant app that holds your entire space or create many little apps that you can compose together and easily make changes in your live world using the editor. + +To create another app it's easiest to just duplicate an existing one and edit the `app.json` to give it a new id and title etc. We have created a Github repository containing a collection of [Hyperfy Recipes](https://github.com/hyperfy-io/hyperfy-recipes) demonstrating how to build different apps using the Hyperfy SDK to get you started. + +## Assets + +All finalized models, sounds, videos or images you reference in your app should be placed in the `assets` folder. These files will be uploaded along with your code. + +## Sources + +You should store any raw source files like `.blend` and `.psd` in the `src` folder. These files won't be uploaded to Hyperfy but are useful if you want to use `git` and `git-lfs` to version control or share your project with others. + +## Scripts + +Each app requires a single `index.js` entrypoint. From there you can import any other js files in the app folder including other npm packages you have installed. + +Your scripts run on both the server and on each client's browser independently. The server is not a central authority and doesn't automatically synchronize script data between clients. The `hyperfy` package provides utilities for synchronizing data, accessing secrets on the server, and running server-only or client-only code. It also provides a bunch of useful utilities out of the box including `Vector3`, `DEG2RAD`, `tween`, etc. See the API Reference for more info. + +## Metadata + +The `app.json` file describes the app and its metadata. + +```json title='app.json' +{ + "id": "fridge", + "name": "Fridge", + "description": "A fridge that opens and closes", + "image": "image.png" +} +``` + +:::caution + +It's important to choose a unique ID for your project, eg by prefixing it with your name or company. It may only contain lowercase alphanumeric characters and hyphens. + +::: + + +## React + +Apps in Hyperfy are built using the declarative power of React – But – instead of rendering to the DOM your app is being rendered into an automatic multiplayer virtual world. Instead of using DOM elements like `
` and `

` you have access to powerful new elements such as `` and ``. + +Everything else is Just Reactβ„’, with full access to component based design, hooks, context and the entire React and Javascript ecosystem. + +Your apps `index.js` file should export a default React component that will be used as the entry point for your app. + +The following is a simple app that displays a large field of grass: + +

+Click to show code + +```jsx +import React from "react"; + +export default function Grass() { + return ( + + + + + + ); +} +``` +
+ +## Performance + +Thanks to React, Hyperfy is able to take full advantage of a virtual dom and only update things that need updating. On top of this, React can schedule complex tasks to happen later at a more optimal time. This is something no other engine can compete with. + +One thing we **must** be careful with, is identifying when to jump into imperative code. + +A perfect example of this is when animating objects each frame. Instead of thrashing React with state updates, it's best to do this _outside_ of react state: + +
+Click to show code + +```jsx +import React, { useRef, useEffect } from 'react' +import { useWorld, Vector3 } from 'hyperfy' + +function MovingBox() { + const ref = useRef() + const world = useWorld() + + useEffect(() => { + const box = ref.current + const position = new Vector3() +// highlight-start + return world.onUpdate(delta => { + position.y += delta + box.setPosition(position) + }) +// highlight-end + }, []) + + return ( + + ) +} +``` +
+ +## Sync State + +While you are free to use React's `useState` hooks in order to change things only for the current avatar, it's likely you'll want to have some or all of your state synchronized with all clients connected to the world so that everyone sees the same thing. + +To do this, export a `getStore` function and then use the `useSyncState` hook to read and write to the distributed store. + +The following example shows a cube that changes color when anyone clicks on it. The color change is observed by **everyone** in the world: + +
+Click to show code + +```jsx +import React from "react"; +// highlight-next-line +import { useSyncState } from "hyperfy"; + +export default function ColorCube() { +// highlight-next-line + const [color, dispatch] = useSyncState((state) => state.color); + return ( + +// highlight-next-line + dispatch("toggle")} /> + + ); +} + +const initialState = { + color: "blue", +}; + +// highlight-start +export function getStore(state = initialState) { + return { + state, + actions: { + toggle(state) { + state.color = state.color === "blue" ? "red" : "blue"; + }, + }, + }; +} +// highlight-end +``` +
+ +Synchronized state is inspired by the flux/redux pattern popular on the web, but instead of dispatching events locally they are distributed across all clients and the server. + +While it may take a second to get used to we've found that this flow is far more efficient and superior to what most platforms do by syncing the component changes of every entity in the space each time they change. This is the ultimate flex for declarative programming that other 3D engines don't have access to. + +## Uploading + +Once you're happy with your app and want to use them in your actual world, you can upload them to Hyperfy with a single command. + +## Checklist + +1. Test your app to make sure it runs well on mobile, desktop and VR devices. +2. Make sure your `app.json` file has the correct metadata that you want. +3. Ensure any files you don't need uploaded are in your `src` folder, not your `assets` folder. + +## Upload to Hyperfy + +Ensure you are connected to Hyperfy. This will open a hyperfy web page, if you are already connected it will tell you to close your browser window, otherwise it will ask you to connect with WalletConnect or MetaMask: + +```bash +npm run connect +``` + +Now build and upload your app, replacing `` with the ID you specified in your `app.json` file: + +```bash +npm run upload +``` + +Once complete you can go to your live world, hit `Tab` to open the editor, click `Add` and switch to the `Uploads` tab where your uploaded apps will be shown. diff --git a/docs/developers/_app.md b/docs/developers/app.md similarity index 100% rename from docs/developers/_app.md rename to docs/developers/app.md diff --git a/docs/developers/components/_category_.json b/docs/developers/components/_category_.json new file mode 100644 index 0000000..3013312 --- /dev/null +++ b/docs/developers/components/_category_.json @@ -0,0 +1,5 @@ +{ + "position": 100, + "label": "Components", + "collapsible": true +} \ No newline at end of file diff --git a/docs/developers/components/background.md b/docs/developers/components/background.md index 982d139..6ab047e 100644 --- a/docs/developers/components/background.md +++ b/docs/developers/components/background.md @@ -2,7 +2,7 @@ Used to change the background color of a world. -This has no effect if you are using a [](./skysphere) as it cannot be seen. +This has no effect if you are using a [``](./skysphere) as it cannot be seen. ## Props diff --git a/docs/developers/components/index.md b/docs/developers/components/index.md deleted file mode 100644 index 4522450..0000000 --- a/docs/developers/components/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -sidebar_position: 100 -sidebar_label: Components ---- diff --git a/docs/developers/destructibles.md b/docs/developers/destructibles.md new file mode 100644 index 0000000..7b57d52 --- /dev/null +++ b/docs/developers/destructibles.md @@ -0,0 +1,2013 @@ +--- +sidebar_label: "Guide: Destructibles" +sidebar_position: 85 +--- + +# Destructibles + +In this tutorial we will be showing you how to create a destructible barrel that tosses anyone caught in its blast radius into the air. We will use many powerful Hyperfy features including sync state, custom fields, file uploads, editor-only helpers, component references, signals, triggers, side effects that resolve over time, NFT ownership checks, and more. + +All of the media used in this project can be found in the `destructibles` app in the [Hyperfy-recipes](https://github.com/hyperfy-io/hyperfy-recipes) repository. Take a moment to download the files in the `/assets` folder and add them to your project to avoid reference errors when we use them later. + +This advanced tutorial expects you to have an understanding of the Hyperfy SDK and Javascript/React code patterns. We won't explain everything in detail so please see the relevant API documentation pages for more information, and feel free to join our [Discord](https://discord.gg/TGtyTEWB2X) and ask questions! + +[Skip to final code](#final-code) + +## App Design + +- While active, only a red barrel is visible +- When the barrel is clicked: + - The barrel disappears + - A sound effect plays + - A light flashes briefly then fades away over time (light can be disabled) + - GIFs appear briefly then disappear + - any user inside a trigger square is shot into the air + - If the user owns at least 1 nft of a specific contract they are protected from this effect (can be disabled) +- When the user is in the editor menu, the following changes will happen until the editor is closed: + - The light turns on + - The gifs appear and loop + - A transparent box appears which scales with the blast radius + - trigger is not visible if the barrel has been exploded, to make it clear it's not active and to tweak visuals +- All variables exposed as editor fields +- Barrel state synchronized across network +- Expose triggers to interact with external apps +- Expose signals so other apps can interact with ours, and the app can be controlled in the editor menu + +## Model + +Start by adding a barrel model with a kinematic rigid body so it interacts with the physics engine. Make sure you have a GLB model in your app's `/assets` folder with the name `barrel.glb`. + +```jsx +import React from "react"; + +export default function Destructible() { + return ( + + //highlight-start + + + + //highlight-end + + ); +} +``` + +## Sync state + +Our app will have two states, active and inactive. By default the app will be active, and when clicked the barrel will be destroyed and will no longer be active. + +To synchronize this state across multiplayer we import the useSyncState hook from the hyperfy library. This hook is very similar to useState: you tell it which variable to bind to and it returns the networked state variable and a dispatch function to send updates to other players. The dispatch actions are defined in the actions section of a getStore function you export (very similar to Redux if you're familiar). + +Import `useSyncState` and use it to create the synchronized state object. Outside of our app's default function add the getStore function with a `Destroy(state)` action (NOTE: the capital D is just to differentiate the two, don't mix them up). + +To make the app interactive we add an `onPointerDown` event to the model. This event fires when a user is in range of the model and clicks. We pass it a callback function `destroy()`, which calls the `dispatch()` function, sending the `Destroy` action across the network. When any client receives the `Destroy` action, the corresponding `Destroy` function is executed, setting the state variable `active` to false. We will use this `active` variable later on. + +
+Click to view code + +```jsx +import React from "react"; +//highlight-next-line +import { useSyncState } from "hyperfy"; + +export default function Destructible() { + //highlight-next-line + const [active, dispatch] = useSyncState((state) => state.active); + + //highlight-start + function destroy() { + dispatch("Destroy"); + } + //highlight-end + + return ( + + + + + + ); +} + +//highlight-start +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + }, + }; +} +//highlight-end +``` + +
+ +## Editor fields + +The barrel currently has no text hint when you hover over it. We can easily add some hard-coded text to this component, but it's usually a good idea to expose these variables to the editor (at least during development). This lets you tweak your variables from within the hyperfy editor, giving consumers of your app a more customizable experience. + +First we import `useFields` from the hyperfy library and call it inside our app function. We use object destructuring syntax on the result to get our `label` variable, which will be tied to a field in the editor UI. We add the `onPointerDownHint` prop to our model and give it the `label` value for the pointer hint. + +A new `fields` property must be added to the object returned from the `getStore` function which contains an array of all the editor fields we want to expose to the user. When destructuring the `useFields` result, the variable names must match a key in the fields array. Now you can change the hover label of the model through the editor! + +
+Click to view code + +```jsx +import React from "react"; +import { + useSyncState, + //highlight-next-line + useFields, +} from "hyperfy"; + +export default function Destructible() { + const [active, dispatch] = useSyncState((state) => state.active); + //highlight-next-line + const { label } = useFields(); + + function destroy() { + dispatch("Destroy"); + } + + return ( + + + + + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + }, + //highlight-start + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + ], + //highlight-end + }; +} +``` + +
+ +## Handle user file uploads + +We can make our barrel model customizable with an editor field as well but we need a new hook to handle the file uploads. The `useFile` hook takes a file uploaded from an editor field and returns a cloud URL of the asset. + +Import `useFile` from hyperfy, add the `model` property to the `useFields` destructure, add a `file` field to the fields array (and a `category` for files as well), pass the `model` to the `useFile` hook, and swap out the src prop in the model component. the `??` syntax will default to our barrel model (this would throw an error without it). + +
+Click to view code + +```jsx +import React from "react"; +import { + useSyncState, + useFields, + //highlight-next-line + useFile, +} from "hyperfy"; + +export default function Destructible() { + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + //highlight-next-line + model, + } = useFields(); + //highlight-next-line + const modelUrl = useFile(model); + + function destroy() { + dispatch("Destroy"); + } + + return ( + + + + + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + //highlight-start + { + type: "section", + label: "Files", + }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + //highlight-end + ], + }; +} +``` + +
+ +## Signals + +If we want other apps to be able to set the state of our custom app we need to add signals. This also allows us to control the app's state from within the editor UI. Import `useSignal` from hyperfy, then call it by passing in the name of our signal `Destroy` as well as a callback function, which in our case is `destroy`. When the `Destroy` signal is received, our app will execute the same exact code as it would if you had clicked on the model. + +While we're in there let's add a `Reset` signal, a corresponding `reset` function, and a sync state action that sets `active` to true. + +The basic state is setup now so let's add some conditional rendering. Check the `active` variable before rendering the `rigidbody` component. The model will now only appear when the state is active. Try clicking on the model as well as playing with the Destroy and Reset buttons in the editor to see the model appear and disappear. + +We'll also quickly add some triggers for Destroy and Reset, which other apps can listen for and react. Import `useWorld` from hyperfy and call it to get a reference to the world object. We add the trigger editor fields and can trigger them using `world.trigger()` but we will do that later. + +
+Click to view code + +```jsx +import React from "react"; +import { + useSyncState, + //highlight-next-line + useWorld, + useFields, + useFile, + //highlight-next-line + useSignal, +} from "hyperfy"; + +export default function Destructible() { + const [active, dispatch] = useSyncState((state) => state.active); + const { label, model } = useFields(); + const modelUrl = useFile(model); + //highlight-next-line + const world = useWorld(); + //highlight-start + useSignal("Reset", reset); + useSignal("Destroy", destroy); + //highlight-end + + //highlight-start + function reset() { + dispatch("Reset"); + } + //highlight-end + + function destroy() { + dispatch("Destroy"); + } + + return ( + + //highlight-start + {active && ( + <> + //highlight-end + + + + //highlight-start + + )} + //highlight-end + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + //highlight-start + Reset(state) { + state.active = true; + }, + //highlight-end + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + { + type: "section", + label: "Files", + }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + //highlight-start + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + //highlight-end + ], + }; +} +``` + +
+ +## Light + +This is where it gets a bit more complex. We are going to add a light component and have it react to the sync state variable. When the state of `active` changes, if the state is `false` we will briefly flash the light and have it fade out over a specified duration. If the state is `true` we will reset everything. + +Add an `arealight` component plus some editor fields to control the properties of the light including position, intensity, and color. Conditionally render the component based on an enablelight editor field, which allows the user to disable the dynamic light. Also, we need to add a `giflifetime` editor field which will be used to clean up the light side effect. + +To adjust the intensity of our light we need to import `useState` from react and create an `intensity` state variable which we can set using `setIntensity`. We also need a reference to our `light` component, so we import `useRef` from react and create a `lightRef` which we pass into the `ref` prop of our light. + +Because we are dealing with side-effects we need to use `useEffect` from react. We will us the `active` variable as our effect's dependency. In order to avoid glitches on the server, we need to check if the code is running on the client or the server. In our effect, we first check if the code is running on the client by checking `!world.isServer`. If the app state is NOT active (meaning it has been destroyed), use `setIntensity` to turn the light on (if it's enabled in the editor field). If our state is active, we reset the light intensity back to 0. + +Next, we create a `world.onUpdate` function, which will be fired every frame and give us a `delta` variable representing the amount of time in seconds since the last frame. We assign this to a variable `cleanup` which will be called later, to unsubscribe from the event. Inside the `onUpdate` function we add up the delta time to our total time elapsed, and if the elapsed time is greater than our `lightlifetime` we set the intensity of the light to 0 and set fading=false to prevent further loops before cleanup. If not enough time has elapsed, we do a linear fade between our maximum intensity and 0, and set the intensity to that value. + +The onUpdate function would run forever like this unless we unsubscribe to it. This is why we use setTimeout to cleanup this function. After a specified duration, the cleanup function is called and the opacity is set to 0. The duration is called `giflifetime` because we will also be cleaning up the gif effect in here later. This might be a messy way to do this in React but it works well enough. + +We also added in the `world.trigger()` calls in the appropriate places now, because this section wasn't big enough.. + +
+Click to view code + +```jsx +import React, { + //highlight-next-line + seState, + //highlight-next-line + useEffect, + //highlight-next-line + useRef, +} from "react"; +import { useSyncState, useWorld, useFields, useFile, useSignal } from "hyperfy"; + +export default function Destructible() { + //highlight-next-line + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + //highlight-next-line + lightcolor, + //highlight-next-line + lightintensity, + //highlight-next-line + lightscale, + //highlight-next-line + lightposition, + //highlight-next-line + lightlifetime, + //highlight-next-line + enablelight, + //highlight-next-line + giflifetime, + model, + } = useFields(); + const modelUrl = useFile(model); + //highlight-next-line + const lightRef = useRef(); + const world = useWorld(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + //highlight-start + useEffect(() => { + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + setIntensity(lightintensity); + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + }, giflifetime); + } else { + world.trigger("Reset"); + setIntensity(0); + } + } + }, [active]); + //highlight-end + + return ( + + {active && ( + <> + + + + + )} + //highlight-start + {enablelight && ( + + )} + //highlight-end + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + + //highlight-start + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + //highlight-end + { + type: "section", + label: "Files", + }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` + +
+ +## GIFs + +Next to add some more visuals to the effect. When the barrel is destroyed, we will briefly show an animated gif and have it play for a few seconds before disappearing. + +We use the `useState` hook to create an `opacity` state object, add new editor fields to upload the gifs and control position/scale, set up file hooks for the uploads, and add the new image components (this is all stuff we've done already). One image will be inside a `billboard` component, which will always face towards the camera but locked to the vertical (y) axis. The other image will be flat on the ground with a fixed rotation. To set the angle of the GIF on the ground we import `DEG2RAD` from hyperfy and multiply it by our degrees to get our rotation in radians. + +Inside our useEffect callback we set opacity to 1 when `active` is false, and set it back to 0 at the end of the `giflifetime` and if `active` is true. + +Finally make sure you have the .gif files in your `assets` folder named `explosion-ground.gif` and `explosion.gif`. + +
+Click to view code + +```jsx +import React, { useState, useEffect, useRef } from "react"; +import { + useSyncState, + useWorld, + useFields, + useFile, + //highlight-next-line + DEG2RAD, + useSignal, +} from "hyperfy"; + +export default function Destructible() { + //highlight-next-line + const [opacity, setOpacity] = useState(0); + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + lightcolor, + lightintensity, + lightscale, + lightposition, + lightlifetime, + enablelight, + giflifetime, + //highlight-next-line + gifscale, + model, + //highlight-next-line + gif, + //highlight-next-line + floorgif, + //highlight-next-line + gifposition, + } = useFields(); + const modelUrl = useFile(model); + //highlight-next-line + const gifUrl = useFile(gif); + //highlight-next-line + const floorgifUrl = useFile(floorgif); + const lightRef = useRef(); + const world = useWorld(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + useEffect(() => { + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + //highlight-next-line + setOpacity(1); + setIntensity(lightintensity); + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + //highlight-next-line + setOpacity(0); + }, giflifetime); + } else { + world.trigger("Reset"); + setIntensity(0); + //highlight-next-line + setOpacity(0); + } + } + }, [active]); + + return ( + + {active && ( + <> + + + + + )} + //highlight-start + + + + + //highlight-end + {enablelight && ( + + )} + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + //highlight-start + { type: "float", key: "gifscale", label: "Gif Scale", initial: 4 }, + { + type: "float", + key: "gifposition", + label: "Gif Y Position", + initial: 2, + }, + //highlight-end + { + type: "section", + label: "Files", + }, + //highlight-start + { type: "file", key: "gif", label: "Air Gif", accept: ".gif" }, + { type: "file", key: "floorgif", label: "Floor Gif", accept: ".gif" }, + //highlight-end + { type: "file", key: "model", label: "Model", accept: ".glb" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` + +
+ +## Sound + +Adding the sound effect is pretty straightfoward now. Add the file editor field, useFile, useRef, and add the audio component and set the ref. Inside our useEffect function we can get the `ref.current` and use that to play the audio with `play()`. We don't have to worry about cleaning this up because we set the audio component to not loop. Make sure you have the .mp3 file in your `assets` folder named `explosion.mp3`. + +
+Click to view code + +```jsx +import React, { useState, useEffect, useRef } from "react"; +import { + useSyncState, + useWorld, + useFields, + useFile, + DEG2RAD, + useSignal, +} from "hyperfy"; + +export default function Destructible() { + const [opacity, setOpacity] = useState(0); + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + lightcolor, + lightintensity, + lightscale, + lightposition, + lightlifetime, + enablelight, + giflifetime, + gifscale, + model, + //highlight-next-line + sound, + gif, + floorgif, + gifposition, + } = useFields(); + const modelUrl = useFile(model); + //highlight-next-line + const soundUrl = useFile(sound); + const gifUrl = useFile(gif); + const floorgifUrl = useFile(floorgif); + //highlight-next-line + const audioRef = useRef(); + const lightRef = useRef(); + const world = useWorld(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + useEffect(() => { + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + //highlight-next-line + audioRef.current.play(); + setOpacity(1); + setIntensity(lightintensity); + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + setOpacity(0); + }, giflifetime); + } else { + world.trigger("Reset"); + setIntensity(0); + setOpacity(0); + } + } + }, [active]); + + return ( + + {active && ( + <> + + + + + )} + + + + + //highlight-start + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + { type: "float", key: "gifscale", label: "Gif Scale", initial: 4 }, + { + type: "float", + key: "gifposition", + label: "Gif Y Position", + initial: 2, + }, + { + type: "section", + label: "Files", + }, + { type: "file", key: "gif", label: "Air Gif", accept: ".gif" }, + { type: "file", key: "floorgif", label: "Floor Gif", accept: ".gif" }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + //highlight-next-line + { type: "file", key: "sound", label: "Sound", accept: ".mp3" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` + +
+ +## Physics + +When the barrel is destroyed, we should have some kind of impact on the user. Let's throw them into the air! A trigger component can be used to keep track of who is inside the blast radius of the barrel. + +We will import `useEditing` and call it to create the `editing` variable. This tells us if the user has the editor open which we will use to show the user a cube which represents the blast radius. Create a new state variable called inRange, which we use to keep track of whether the local player is in range of the trigger box. We need to add two new editor fields, blastradius which will determine the size of the trigger box, and upwardforce which will be the force applied to any user inside the trigger upon destruction. + +Create two callback functions, one for a user entering the trigger box and one for a user leaving the trigger box. When other users enter the box we want to ignore them (we can only apply physics force on the local avatar), so check if the user that entered/exited the box has the same user ID as the local client, and if so set the `inRange` state accordingly. Now in the useEffect, we can check if the user is inRange, and if so we use `world.applyUpwareForce()` to send them flying into the sky. + +We conditionally render a sem-transparent red box with the same size as the `blastradius` editor variable, which acts as a scale guide for the trigger. Boxes and triggers have the same scale so a box is a useful stand-in to visualize the position and scale of a trigger. This box only shows up when the app state is active and the editor is open. Add the trigger component and hook it up to the callback functions we defined earlier. + +Finally, we can add some extra utility to the editor window by conditionally setting props of the light and images when the editor is open. This lets us tweak the light and image properties without having to reset the app constantly. + +
+Click to view code + +```jsx +import React, { useState, useEffect, useRef } from "react"; +import { + useSyncState, + useWorld, + useFields, + useFile, + //highlight-next-line + useEditing, + DEG2RAD, + useSignal, +} from "hyperfy"; + +export default function Destructible() { + const [opacity, setOpacity] = useState(0); + //highlight-next-line + const [inRange, setInRange] = useState(false); + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + lightcolor, + lightintensity, + lightscale, + lightposition, + lightlifetime, + enablelight, + //highlight-next-line + blastradius, + //highlight-next-line + upwardforce, + giflifetime, + gifscale, + model, + sound, + gif, + floorgif, + gifposition, + } = useFields(); + const modelUrl = useFile(model); + const soundUrl = useFile(sound); + const gifUrl = useFile(gif); + const floorgifUrl = useFile(floorgif); + const audioRef = useRef(); + const lightRef = useRef(); + const world = useWorld(); + //highlight-next-line + const editing = useEditing(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + //highlight-start + function enterRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(true); + } + } + //highlight-end + + //highlight-start + function leaveRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(false); + } + } + //highlight-end + + useEffect(() => { + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + audioRef.current.play(); + setOpacity(1); + setIntensity(lightintensity); + + //highlight-next-line + if (inRange) { + //highlight-next-line + world.applyUpwardForce(upwardforce); + //highlight-next-line + } + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + setOpacity(0); + }, giflifetime); + } else { + world.trigger("Reset"); + setIntensity(0); + setOpacity(0); + } + } + }, [active]); + + return ( + + {active && ( + <> + //highlight-next-line + {editing && } + + + + + )} + //highlight-next-line + + + + + + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + //highlight-start + { + type: "section", + label: "Explosion", + }, + { type: "float", key: "blastradius", label: "Blast Radius", initial: 3 }, + { type: "float", key: "upwardforce", label: "Upward Force", initial: 20 }, + //highlight-end + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + { type: "float", key: "gifscale", label: "Gif Scale", initial: 4 }, + { + type: "float", + key: "gifposition", + label: "Gif Y Position", + initial: 2, + }, + { + type: "section", + label: "Files", + }, + { type: "file", key: "gif", label: "Air Gif", accept: ".gif" }, + { type: "file", key: "floorgif", label: "Floor Gif", accept: ".gif" }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + { type: "file", key: "sound", label: "Sound", accept: ".mp3" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` + +
+ +## Blockchain + +The last thing we'll add is NFT armor! This will protect anyone holding a balance of at least 1 of a specified NFT contract. Holders will not be knocked up in the air by the explosion (with an option to disable armor). + +We import the `useEth` hook from hyperfy, create a new state variable `balance` to hold a user's balance, hook up a couple new editor fields for the contract and the toggle to disable armor, and call `useEth()` to get an instance of `eth`. By passing in no chain into `useEth()` we get an Ethereum instance. + +We cannot call an async function inside useEffect, but we can define an async function inside the effect and call that. We create a `getBalance()` async function inside our effect, which we call whenever the component is initialized or reset. Now we can use the `balance` and conditionally apply the upward force based on it (or ignore balance if the option is set) + +
+Click to view code + +```jsx +import React, { useState, useEffect, useRef } from "react"; +import { + useSyncState, + useWorld, + useFields, + useFile, + useEditing, + DEG2RAD, + useSignal, + //highlight-next-line + useEth, +} from "hyperfy"; + +export default function Destructible() { + const [opacity, setOpacity] = useState(0); + const [inRange, setInRange] = useState(false); + //highlight-next-line + const [balance, setBalance] = useState(0); + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + lightcolor, + lightintensity, + lightscale, + lightposition, + lightlifetime, + enablelight, + //highlight-next-line + nftarmor, + //highlight-next-line + nftarmorcontract, + blastradius, + upwardforce, + giflifetime, + gifscale, + model, + sound, + gif, + floorgif, + gifposition, + } = useFields(); + const modelUrl = useFile(model); + const soundUrl = useFile(sound); + const gifUrl = useFile(gif); + const floorgifUrl = useFile(floorgif); + const audioRef = useRef(); + const lightRef = useRef(); + const world = useWorld(); + const editing = useEditing(); + //highlight-next-line + const eth = useEth(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + function enterRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(true); + } + } + + function leaveRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(false); + } + } + + useEffect(() => { + //highlight-start + const getBalance = async () => { + const chain = await eth.getChain(); + const address = world.getAvatar()?.address; + if (chain && address) { + const contract = eth.contract(nftarmorcontract); + const worlds = await contract.read("balanceOf", address); + setBalance(worlds); + } else { + setBalance(0); + } + }; + //highlight-end + + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + audioRef.current.play(); + setOpacity(1); + setIntensity(lightintensity); + + //highlight-next-line + if (inRange && (balance < 1 || !nftarmor)) { + world.applyUpwardForce(upwardforce); + } + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + setOpacity(0); + }, giflifetime); + } else { + //highlight-next-line + getBalance(); + world.trigger("Reset"); + setIntensity(0); + setOpacity(0); + } + } + }, [active]); + + return ( + + {active && ( + <> + {editing && } + + + + + )} + + + + + + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + { + type: "section", + label: "Explosion", + }, + //highlight-start + { + type: "switch", + key: "nftarmor", + label: "NFT Armor", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "nftarmorcontract", + label: "NFT Armor Contract", + initial: "0xf53b18570db14c1e7dbc7dc74538c48d042f1332", + }, + //highlight-end + { type: "float", key: "blastradius", label: "Blast Radius", initial: 3 }, + { type: "float", key: "upwardforce", label: "Upward Force", initial: 20 }, + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + { type: "float", key: "gifscale", label: "Gif Scale", initial: 4 }, + { + type: "float", + key: "gifposition", + label: "Gif Y Position", + initial: 2, + }, + { + type: "section", + label: "Files", + }, + { type: "file", key: "gif", label: "Air Gif", accept: ".gif" }, + { type: "file", key: "floorgif", label: "Floor Gif", accept: ".gif" }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + { type: "file", key: "sound", label: "Sound", accept: ".mp3" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` + +
+ +And that's it! Feel free to remix this app and share the stuff you come up with! + +## Final code + +```jsx +import React, { useState, useEffect, useRef } from "react"; +import { + useSyncState, + useWorld, + useFields, + useFile, + useEditing, + DEG2RAD, + useSignal, + useEth, +} from "hyperfy"; + +export default function Destructible() { + const [opacity, setOpacity] = useState(0); + const [inRange, setInRange] = useState(false); + const [balance, setBalance] = useState(0); + const [intensity, setIntensity] = useState(0); + const [active, dispatch] = useSyncState((state) => state.active); + const { + label, + lightcolor, + lightintensity, + lightscale, + lightposition, + lightlifetime, + enablelight, + nftarmor, + nftarmorcontract, + blastradius, + upwardforce, + giflifetime, + gifscale, + model, + sound, + gif, + floorgif, + gifposition, + } = useFields(); + const modelUrl = useFile(model); + const soundUrl = useFile(sound); + const gifUrl = useFile(gif); + const floorgifUrl = useFile(floorgif); + const audioRef = useRef(); + const lightRef = useRef(); + const world = useWorld(); + const editing = useEditing(); + const eth = useEth(); + useSignal("Reset", reset); + useSignal("Destroy", destroy); + + function reset() { + dispatch("Reset"); + } + + function destroy() { + dispatch("Destroy"); + } + + function enterRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(true); + } + } + + function leaveRange(e) { + const localUid = world.getAvatar(); + if (localUid.uid == e) { + setInRange(false); + } + } + + useEffect(() => { + const getBalance = async () => { + const chain = await eth.getChain(); + const address = world.getAvatar()?.address; + if (chain && address) { + const contract = eth.contract(nftarmorcontract); + const worlds = await contract.read("balanceOf", address); + setBalance(worlds); + } else { + setBalance(0); + } + }; + + if (!world.isServer) { + if (!active) { + world.trigger("Destroy"); + audioRef.current.play(); + setOpacity(1); + setIntensity(lightintensity); + + if (inRange && (balance < 1 || !nftarmor)) { + world.applyUpwardForce(upwardforce); + } + + let timeElapsed = 0; + let fading = true; + const cleanup = world.onUpdate((delta) => { + if (fading) { + timeElapsed += delta * 1000; + if (timeElapsed >= lightlifetime) { + setIntensity(0); + fading = false; + } else { + const intensity = + (1 - timeElapsed / lightlifetime) * lightintensity; + setIntensity(intensity); + } + } + }); + + setTimeout(() => { + cleanup(); + setIntensity(0); + setOpacity(0); + }, giflifetime); + } else { + getBalance(); + world.trigger("Reset"); + setIntensity(0); + setOpacity(0); + } + } + }, [active]); + + return ( + + {active && ( + <> + {editing && } + + + + + )} + + + + + + + ); +} + +export function getStore(state = { active: true }) { + return { + state, + actions: { + Destroy(state) { + state.active = false; + }, + Reset(state) { + state.active = true; + }, + }, + fields: [ + { type: "text", key: "label", label: "Hover label", initial: "Explode" }, + { + type: "section", + label: "Light", + }, + { + type: "switch", + key: "enablelight", + label: "Enable Light", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "lightcolor", + label: "Light Color", + initial: "orange", + }, + { + type: "float", + key: "lightintensity", + label: "Light Intensity", + initial: 1000, + }, + { + type: "float", + key: "lightscale", + label: "Light Scale", + initial: 10, + }, + { + type: "float", + key: "lightposition", + label: "Light Position", + initial: 10, + }, + { + type: "float", + key: "lightlifetime", + label: "Light Lifetime", + initial: 1000, + }, + { + type: "section", + label: "Explosion", + }, + { + type: "switch", + key: "nftarmor", + label: "NFT Armor", + options: [ + { label: "true", value: true }, + { label: "false", value: false }, + ], + initial: true, + }, + { + type: "text", + key: "nftarmorcontract", + label: "NFT Armor Contract", + initial: "0xf53b18570db14c1e7dbc7dc74538c48d042f1332", + }, + { type: "float", key: "blastradius", label: "Blast Radius", initial: 3 }, + { type: "float", key: "upwardforce", label: "Upward Force", initial: 20 }, + { + type: "section", + label: "GIFs", + }, + { + type: "float", + key: "giflifetime", + label: "Gif Lifetime", + initial: 4000, + }, + { type: "float", key: "gifscale", label: "Gif Scale", initial: 4 }, + { + type: "float", + key: "gifposition", + label: "Gif Y Position", + initial: 2, + }, + { + type: "section", + label: "Files", + }, + { type: "file", key: "gif", label: "Air Gif", accept: ".gif" }, + { type: "file", key: "floorgif", label: "Floor Gif", accept: ".gif" }, + { type: "file", key: "model", label: "Model", accept: ".glb" }, + { type: "file", key: "sound", label: "Sound", accept: ".mp3" }, + { + type: "section", + label: "Triggers", + }, + { + type: "trigger", + name: "Destroy", + }, + { + type: "trigger", + name: "Reset", + }, + ], + }; +} +``` diff --git a/docs/developers/hooks/_category_.json b/docs/developers/hooks/_category_.json new file mode 100644 index 0000000..1edfa0c --- /dev/null +++ b/docs/developers/hooks/_category_.json @@ -0,0 +1,5 @@ +{ + "position": 90, + "label": "Hooks", + "collapsible": true +} \ No newline at end of file diff --git a/docs/developers/hooks/image-1.png b/docs/developers/hooks/image-1.png new file mode 100644 index 0000000..5a0604e Binary files /dev/null and b/docs/developers/hooks/image-1.png differ diff --git a/docs/developers/hooks/index.md b/docs/developers/hooks/index.md deleted file mode 100644 index 327c13f..0000000 --- a/docs/developers/hooks/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -sidebar_position: 90 -sidebar_label: Hooks ---- diff --git a/docs/developers/hooks/use-editing.md b/docs/developers/hooks/use-editing.md index aaf170d..7e2c6c2 100644 --- a/docs/developers/hooks/use-editing.md +++ b/docs/developers/hooks/use-editing.md @@ -5,11 +5,14 @@ A React hook that returns true if the editor is open. Useful for displaying objects for positioning, especially when an app would otherwise be invisible to everyone else. ```jsx +import React from 'react' + //highlight-next-line import { useEditing } from "hyperfy"; export default function App() { + //highlight-next-line const editing = useEditing(); - return {editing && }; + return {editing && }; } ``` diff --git a/docs/developers/hooks/use-entity-uid.md b/docs/developers/hooks/use-entity-uid.md index 843da15..55febe0 100644 --- a/docs/developers/hooks/use-entity-uid.md +++ b/docs/developers/hooks/use-entity-uid.md @@ -5,14 +5,17 @@ A React hook used to get the ID of the current entity. Useful if you need a unique local ID or to emit an event with data describing who emitted it. ```jsx +import React from 'react' + //highlight-next-line import { useEntityUid } from "hyperfy"; export default function App() { + //highlight-next-line const entityId = useEntityUid(); return ( - + ); } diff --git a/docs/developers/hooks/use-eth.md b/docs/developers/hooks/use-eth.md index c739f0a..fc59abe 100644 --- a/docs/developers/hooks/use-eth.md +++ b/docs/developers/hooks/use-eth.md @@ -14,16 +14,59 @@ Returns info about the chain the current user is connected to, or null if they a This is useful to check that the user is connected to the correct network before calling a contract or making a payment. -```jsx -const chain = await eth.getChain(); -``` - **Eth.sign(message)** Returns a `Promise` that resolves with the signature `String` once the user signs using their wallet. ```jsx -const signature = await eth.sign("Howdy!"); +import React from "react"; +//highlight-next-line +import { useEth } from "hyperfy"; + +export default function App() { + //highlight-next-line + const eth = useEth(); + async function signEth() { + //highlight-next-line + const chainInfo = await eth.getChain(); + if (chainInfo) { + console.log(chainInfo); + //highlight-next-line + const signature = await eth.sign("Howdy Ethereum!"); + console.log(signature); + } + } + + //highlight-next-line + const op = useEth("optimism"); + async function signOp() { + //highlight-next-line + const chainInfo = await op.getChain(); + if (chainInfo) { + console.log(chainInfo); + //highlight-next-line + const signature = await op.sign("Howdy Optimism!"); + console.log(signature); + } + } + + return ( + + + + + ); +} ``` **Eth.pay(address, amountInWei)** @@ -32,54 +75,114 @@ Requests the user to make a payment to an address. Returns a `Promise` that resolves with a `Transaction` instance. -```jsx -const tx = await eth.pay("0x00", eth.toWei("2.4")); -await tx.wait(); -console.log("Paid!"); -``` - **Eth.toWei(amountInEth)** Converts an eth string to wei. Returns a `String`. +```jsx +import React from "react"; +//highlight-next-line +import { useEth } from "hyperfy"; + +const WALLET = "0xf53b18570db14c1e7dbc7dc74538c48d042f1332"; //Hyperfy worlds contract +const ETH_AMOUNT = "0.69"; + +export default function App() { + //highlight-next-line + const eth = useEth(); + + async function payEth() { + if (await eth.getChain()) { + //highlight-next-line + const amount = eth.toWei(ETH_AMOUNT); + //highlight-next-line + const tx = await eth.pay(WALLET, amount); + //highlight-next-line + await tx.wait(); + } + } + + return ( + + + + ); +} +``` + **Eth.contract(address[, abi])** Returns an instance of `Contract` for the specified address. If no ABI is specified we will automatically load it for you. Providing your own ABI is useful to improve speed or when the ABI can't be loaded automatically. -```jsx -// ABI not provided (fetched automatically) -const contract = useMemo(() => eth.contract("0x000...")); - -// ABI provided manually (faster) -const contract = useMemo(() => - eth.contract("0x000", ["function mint(uint256 amount) external payable"]) -); -``` - **Contract.read(method, ...args)** Returns a `Promise` that resolves with the result of the read method. -```jsx -const balance = await contract.read("balanceOf", "0x123"); -``` - **Contract.write(method, ...args)** Returns a `Promise` that resolves with a `Transaction` -```jsx -const tx = await contract.write("mint", 1, { value: eth.toWei("0.06") }); -``` - **Transaction.wait()** Returns a `Promise` that resolves once the transaction is confirmed on the blockchain. ```jsx -const tx = await contract.write("mint", 1, { value: eth.toWei("0.06") }); -await tx.wait(); -console.log("Minted!"); +import React, { useMemo } from "react"; +//highlight-next-line +import { useEth } from "hyperfy"; + +const WALLET = "0xf53b18570db14c1e7dbc7dc74538c48d042f1332"; //Hyperfy worlds contract +const MINT_PRICE = "0.06"; + +export default function App() { + //highlight-next-line + const eth = useEth(); + //highlight-next-line + const contract = useMemo(() => eth.contract(WALLET), []); + + async function getBalance() { + if (await eth.getChain()) { + //highlight-next-line + const balance = await contract.read("balanceOf"); + console(`Balance: ${balance}`); + } + } + + async function mintWorld() { + if (await eth.getChain()) { + //highlight-next-line + const tx = await contract.write("mint", 1, { + value: eth.toWei(MINT_PRICE), + }); + console.log("Verifying..."); + //highlight-next-line + await tx.wait(); + console.log("Minted!"); + } + } + + return ( + + + + + ); +} ``` diff --git a/docs/developers/hooks/use-fields.md b/docs/developers/hooks/use-fields.md index 4c990c0..4c29d0c 100644 --- a/docs/developers/hooks/use-fields.md +++ b/docs/developers/hooks/use-fields.md @@ -33,22 +33,26 @@ Each field is an object with properties that revolve around a `type`. ## Example ```jsx +import React from 'react' +//highlight-next-line import { useFields, useFile } from "hyperfy"; export default function App() { +//highlight-start const fields = useFields(); - const { text, float, switchValue, dropdownValue, file, position } = fields; + const { text, float, switchValue, dropdownValue, file, position, color } = fields; +//highlight-end const fileUrl = useFile(file); return ( - - + `} color={color}/> + ) } @@ -61,6 +65,7 @@ export const getStore = (state = initialState) => { return { state, actions: {}, +//highlight-start fields: [ { type: "text", @@ -106,13 +111,24 @@ export const getStore = (state = initialState) => { type: "vec3", key: "position", label: "Position", - initial: [0, 0, 0], + initial: [0, 0.5, 0], + }, + { + type: "color", + key: "color", + label: "Color", + initial: "white", }, { type: "section", label: "Section", } ], +//highlight-end }; }; ``` + +What this app looks like in the Hyperfy editor UI: + +![Editor Fields](image-1.png) \ No newline at end of file diff --git a/docs/developers/hooks/use-file.md b/docs/developers/hooks/use-file.md index b457fbe..aab4c3c 100644 --- a/docs/developers/hooks/use-file.md +++ b/docs/developers/hooks/use-file.md @@ -12,15 +12,19 @@ Accepted file types: ## Example ```jsx +import React from 'react' +//highlight-next-line import { useFields, useFile } from "hyperfy"; export default function App() { const { image } = useFields(); +//highlight-next-line const fileUrl = useFile(image); return ( - +//highlight-next-line + ) } @@ -39,7 +43,7 @@ export const getStore = (state = initialState) => { key: "image", label: "Image", accept: ".png", - // accept: ".png, .jpg, jpeg, .gif, .glb, .vrm, .mp4, .mp3", + // accept: ".png, .jpg, jpeg, .gif, .svg, .glb, .vrm, .mp4, .mp3", }, ], }; diff --git a/docs/developers/hooks/use-sync-state.md b/docs/developers/hooks/use-sync-state.md index b92774e..2a00916 100644 --- a/docs/developers/hooks/use-sync-state.md +++ b/docs/developers/hooks/use-sync-state.md @@ -3,15 +3,19 @@ A React hook used to read and write state distributed across all clients. ```jsx +import React from "react"; +//highlight-next-line import { useSyncState } from "hyperfy"; export default function Box() { + //highlight-next-line const [blue, dispatch] = useSyncState((state) => state.blue); return ( dispatch("toggle")} /> @@ -26,9 +30,11 @@ export function getStore(state = initialState) { return { state, actions: { + //highlight-start toggle(state) { state.blue = !state.blue; }, + //highlight-end }, }; } diff --git a/docs/developers/hooks/use-world.md b/docs/developers/hooks/use-world.md index 2f84c89..8dea60f 100644 --- a/docs/developers/hooks/use-world.md +++ b/docs/developers/hooks/use-world.md @@ -3,16 +3,17 @@ A React hook that provides access to the underlying engine. ```jsx +import React from "react"; +//highlight-next-line import { useWorld } from "hyperfy"; -function Box() { +export default function Box() { + //highlight-start const world = useWorld(); + world.chat("Hello world!"); + //highlight-end - if (world.isServer) { - console.log(`I'm running on the server!`); - } - - return ; + return ; } ``` @@ -52,6 +53,46 @@ Example: when in https://hyperfy.io/meadow/~k0h1 `world.getShard()` will return Returns the current time of the server in milliseconds. This time is also synchronized across and available to all clients. +```jsx +import React from "react"; +//highlight-next-line +import { useWorld } from "hyperfy"; + +export default function Box() { + //highlight-next-line + const world = useWorld(); + + //highlight-start + if (world.isServer) { + console.log(`I'm running on the server!`); + } + if (world.isClient) { + console.log(`I'm running on a client!`); + } + //highlight-end + + return ( + + //highlight-start + + //highlight-end + + ); +} +``` + ### .getAvatar(avatarUid) Returns an `Avatar` reference. If no avatarUid is provided it returns the local avatar. @@ -60,42 +101,114 @@ Returns an `Avatar` reference. If no avatarUid is provided it returns the local Returns an array of `Avatar` references -### .getAudioAnalyser(sourceId) - -Returns an AudioAnalyser that targets a sourceId from `