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 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 and ask questions!
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
.
import React from "react";
export default function Destructible() {
return (
<app>
<rigidbody type="kinematic">
<model src="barrel.glb" collision="trimesh" />
</rigidbody>
</app>
);
}
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
import React from "react";
import { useSyncState } from "hyperfy";
export default function Destructible() {
const [active, dispatch] = useSyncState((state) => state.active);
function destroy() {
dispatch("Destroy");
}
return (
<app>
<rigidbody type="kinematic">
<model
src="barrel.glb"
collision="trimesh"
onPointerDown={destroy}
/>
</rigidbody>
</app>
);
}
export function getStore(state = { active: true }) {
return {
state,
actions: {
Destroy(state) {
state.active = false;
},
},
};
}
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
import React from "react";
import {
useSyncState,
useFields,
} from "hyperfy";
export default function Destructible() {
const [active, dispatch] = useSyncState((state) => state.active);
const { label } = useFields();
function destroy() {
dispatch("Destroy");
}
return (
<app>
<rigidbody type="kinematic">
<model
src="barrel.glb"
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</app>
);
}
export function getStore(state = { active: true }) {
return {
state,
actions: {
Destroy(state) {
state.active = false;
},
},
fields: [
{ type: "text", key: "label", label: "Hover label", initial: "Explode" },
],
};
}
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
import React from "react";
import {
useSyncState,
useFields,
useFile,
} from "hyperfy";
export default function Destructible() {
const [active, dispatch] = useSyncState((state) => state.active);
const {
label,
model,
} = useFields();
const modelUrl = useFile(model);
function destroy() {
dispatch("Destroy");
}
return (
<app>
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</app>
);
}
export function getStore(state = { active: true }) {
return {
state,
actions: {
Destroy(state) {
state.active = false;
},
},
fields: [
{ type: "text", key: "label", label: "Hover label", initial: "Explode" },
{
type: "section",
label: "Files",
},
{ type: "file", key: "model", label: "Model", accept: ".glb" },
],
};
}
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
import React from "react";
import {
useSyncState,
useWorld,
useFields,
useFile,
useSignal,
} from "hyperfy";
export default function Destructible() {
const [active, dispatch] = useSyncState((state) => state.active);
const { label, model } = useFields();
const modelUrl = useFile(model);
const world = useWorld();
useSignal("Reset", reset);
useSignal("Destroy", destroy);
function reset() {
dispatch("Reset");
}
function destroy() {
dispatch("Destroy");
}
return (
<app>
{active && (
<>
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
</app>
);
}
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: "Files",
},
{ type: "file", key: "model", label: "Model", accept: ".glb" },
{
type: "section",
label: "Triggers",
},
{
type: "trigger",
name: "Destroy",
},
{
type: "trigger",
name: "Reset",
},
],
};
}
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
import React, {
seState,
useEffect,
useRef,
} from "react";
import { useSyncState, useWorld, useFields, useFile, useSignal } from "hyperfy";
export default function Destructible() {
const [intensity, setIntensity] = useState(0);
const [active, dispatch] = useSyncState((state) => state.active);
const {
label,
lightcolor,
lightintensity,
lightscale,
lightposition,
lightlifetime,
enablelight,
giflifetime,
model,
} = useFields();
const modelUrl = useFile(model);
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");
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]);
return (
<app>
{active && (
<>
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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: "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
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,
gif,
floorgif,
gifposition,
} = useFields();
const modelUrl = useFile(model);
const gifUrl = useFile(gif);
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");
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 (
<app>
{active && (
<>
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
<billboard axis="y">
<image
src={gifUrl ?? "explosion.gif"}
scale={gifscale}
position={[0, gifposition, 0]}
opacity={opacity}
/>
</billboard>
<image
src={floorgifUrl ?? "explosion-ground.gif"}
position={[0, 0.05, 0]}
rotation={[-90 * DEG2RAD, 0, 0]}
scale={gifscale}
opacity={opacity}
/>
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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" },
{
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
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,
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();
useSignal("Reset", reset);
useSignal("Destroy", destroy);
function reset() {
dispatch("Reset");
}
function destroy() {
dispatch("Destroy");
}
useEffect(() => {
if (!world.isServer) {
if (!active) {
world.trigger("Destroy");
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 (
<app>
{active && (
<>
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
<billboard axis="y">
<image
src={gifUrl ?? "explosion.gif"}
scale={gifscale}
position={[0, gifposition, 0]}
opacity={opacity}
/>
</billboard>
<image
src={floorgifUrl ?? "explosion-ground.gif"}
position={[0, 0.05, 0]}
rotation={[-90 * DEG2RAD, 0, 0]}
scale={gifscale}
opacity={opacity}
/>
<audio
src={soundUrl ?? "explosion.mp3"}
loop={false}
autoplay={false}
ref={audioRef}
/>
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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" },
{ 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
import React, { useState, useEffect, useRef } from "react";
import {
useSyncState,
useWorld,
useFields,
useFile,
useEditing,
DEG2RAD,
useSignal,
} from "hyperfy";
export default function Destructible() {
const [opacity, setOpacity] = useState(0);
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,
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();
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(() => {
if (!world.isServer) {
if (!active) {
world.trigger("Destroy");
audioRef.current.play();
setOpacity(1);
setIntensity(lightintensity);
if (inRange) {
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 {
world.trigger("Reset");
setIntensity(0);
setOpacity(0);
}
}
}, [active]);
return (
<app>
{active && (
<>
{editing && <box size={blastradius} opacity={0.15} color="red" />}
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
<trigger size={blastradius} onEnter={enterRange} onLeave={leaveRange} />
<billboard axis="y">
<image
src={gifUrl ?? "explosion.gif"}
scale={gifscale}
position={[0, gifposition, 0]}
opacity={editing ? 1 : opacity}
/>
</billboard>
<image
src={floorgifUrl ?? "explosion-ground.gif"}
position={[0, 0.05, 0]}
rotation={[-90 * DEG2RAD, 0, 0]}
scale={gifscale}
opacity={editing ? 1 : opacity}
/>
<audio
src={soundUrl ?? "explosion.mp3"}
loop={false}
autoplay={false}
ref={audioRef}
/>
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={editing ? lightintensity : intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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: "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",
},
],
};
}
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
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 (
<app>
{active && (
<>
{editing && <box size={blastradius} opacity={0.15} color="red" />}
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
<trigger size={blastradius} onEnter={enterRange} onLeave={leaveRange} />
<billboard axis="y">
<image
src={gifUrl ?? "explosion.gif"}
scale={gifscale}
position={[0, gifposition, 0]}
opacity={editing ? 1 : opacity}
/>
</billboard>
<image
src={floorgifUrl ?? "explosion-ground.gif"}
position={[0, 0.05, 0]}
rotation={[-90 * DEG2RAD, 0, 0]}
scale={gifscale}
opacity={editing ? 1 : opacity}
/>
<audio
src={soundUrl ?? "explosion.mp3"}
loop={false}
autoplay={false}
ref={audioRef}
/>
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={editing ? lightintensity : intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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",
},
],
};
}
And that's it! Feel free to remix this app and share the stuff you come up with!
Final code
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 (
<app>
{active && (
<>
{editing && <box size={blastradius} opacity={0.15} color="red" />}
<rigidbody type="kinematic">
<model
src={modelUrl ?? "barrel.glb"}
collision="trimesh"
onPointerDown={destroy}
onPointerDownHint={label}
/>
</rigidbody>
</>
)}
<trigger size={blastradius} onEnter={enterRange} onLeave={leaveRange} />
<billboard axis="y">
<image
src={gifUrl ?? "explosion.gif"}
scale={gifscale}
position={[0, gifposition, 0]}
opacity={editing ? 1 : opacity}
/>
</billboard>
<image
src={floorgifUrl ?? "explosion-ground.gif"}
position={[0, 0.05, 0]}
rotation={[-90 * DEG2RAD, 0, 0]}
scale={gifscale}
opacity={editing ? 1 : opacity}
/>
<audio
src={soundUrl ?? "explosion.mp3"}
loop={false}
autoplay={false}
ref={audioRef}
/>
{enablelight && (
<arealight
ref={lightRef}
color={lightcolor}
position={[0, lightposition, 0]}
intensity={editing ? lightintensity : intensity}
depth={lightscale}
width={lightscale}
/>
)}
</app>
);
}
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",
},
],
};
}