component
Interactable
Wrap anything to make it something the player can act on: walk within range and a prompt shows its actions with the keys the world binds to them; press one and onAction gets its id. Only the nearest in range takes the key, and keys yield to text fields.
⌖ Explore the library in 3D → · View as Markdown
A wrapper: put it around anything to give the player a key prompt and actions.
Add it
npx @runek/cli add interactable
Pulls sign, @react-three/drei@^10.7.7, @runek/core@^0.13.0, three@^0.184.0.
Use it
import { Interactable } from './runek/Interactable'
<Interactable position={[0, 0, 0]} />
Or as a node in a world file (worlds as data):
{ "type": "Interactable", "props": { "position": [0, 0, 0] } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
actions | InteractionAction[] | [] | What the player can do here. Each names a world controls action for its key, so declare it in the world (controls: { use: ['KeyE'] }). |
radius | number | 2 | How close the avatar must come for the prompt to show, in units. |
promptHeight | number | 1.4 | Height of the prompt above the origin, in units. |
onAction | function (code only) | ||
children | node (code only) | ||
position | [x, y, z] | [0, 0, 0] | Position [x, y, z] in units (1 unit = 1 m, Y-up). |
rotation | [x, y, z] | [0, 0, 0] | Euler rotation [x, y, z] in radians. |
seed | number | Accepted for the component contract; this component has no seeded variation yet. |
InteractionAction
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Passed back to onAction. |
label | string | required | Shown in the prompt, e.g. Talk. |
control | string | required | The world controls action that triggers it (declare talk: ['KeyT'] in the world’s controls). The prompt shows the key bound to it, so a remap relabels it. |
Registry manifest
registry manifest /r/components/interactable.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add interactable fetches.
Browse the whole catalog in the gallery →.
Source (Interactable.tsx)
import { Billboard } from '@react-three/drei'
import {
type InteractionAction,
keyLabel,
useInteraction,
useWorld,
type Vec3,
type WorldComponentProps,
} from '@runek/core'
import { type ReactNode, useRef } from 'react'
import type { Group } from 'three'
import { Sign } from './Sign'
export interface InteractableProps extends WorldComponentProps {
/** What the player can do here. Each names a world `controls` action for its key, so declare
* it in the world (`controls: { use: ['KeyE'] }`). */
actions?: InteractionAction[]
/** How close the avatar must come for the prompt to show, in units. */
radius?: number
/** Height of the prompt above the origin, in units. */
promptHeight?: number
onAction?: (id: string) => void
children?: ReactNode
}
export interface InteractionPromptProps {
actions: InteractionAction[]
position?: Vec3
/** Cap height of the text, in units. */
size?: number
}
const INK = '#f6f1e7'
/** The key-and-label rows over something you can act on: one row per action, its key cap showing
* the key the world binds to it (so a remap relabels it). Always faces the camera. */
export function InteractionPrompt({ actions, position, size = 0.09 }: InteractionPromptProps) {
const { unit, palette, controls } = useWorld()
const s = size * unit
const row = s * 2.3
return (
<Billboard position={position}>
{actions.map((action, i) => {
const code = controls[action.control]?.[0]
const key = code ? keyLabel(code) : '?'
const cap = Math.max(1, key.length * 0.62) * s + s * 0.9
const y = (actions.length - 1 - i) * row
return (
<group key={action.id} position={[0, y, 0]}>
<mesh position={[-cap / 2, 0, 0]} renderOrder={1}>
<planeGeometry args={[cap, s * 1.75]} />
<meshBasicMaterial color={palette.accent} toneMapped={false} />
</mesh>
<Sign
position={[-cap / 2, 0, 0.002 * unit]}
variant="body"
size={size}
color={palette.metal}
>
{key}
</Sign>
<Sign
position={[s * 0.6, 0, 0]}
variant="body"
size={size}
color={INK}
outline={palette.metal}
anchorX="left"
>
{action.label}
</Sign>
</group>
)
})}
</Billboard>
)
}
/**
* Wrap anything to make it something the player can act on: walk within `radius` and a prompt
* appears over it (`E Use`), press the key and `onAction` gets the action's `id`. Of everything
* in range only the nearest shows its prompt and takes the key, and keys already yield to text
* fields on the page. The actions are plain JSON, so a world file can declare them; the callback
* is the host's. No geometry or colliders of its own.
*
* ```tsx
* <World controls={{ use: ['KeyE'] }}>
* <Interactable actions={[{ id: 'sit', label: 'Sit', control: 'use' }]} onAction={sitDown}>
* <Chair />
* </Interactable>
* </World>
* ```
*/
export function Interactable({
position = [0, 0, 0],
rotation = [0, 0, 0],
actions = [],
radius = 2,
promptHeight = 1.4,
onAction,
children,
}: InteractableProps) {
const { unit } = useWorld()
const anchor = useRef<Group>(null)
const focused = useInteraction(anchor, { actions, radius, onAction })
return (
<group ref={anchor} position={position} rotation={rotation}>
{children}
{focused && <InteractionPrompt actions={actions} position={[0, promptHeight * unit, 0]} />}
</group>
)
}