Skip to content

Commit c65544e

Browse files
feat: add InteractableArea react-ecs component (#1411)
## Summary - Adds `InteractableArea` to `@dcl/react-ecs`, mirroring `ScreenInsetArea` but reading from `UiCanvasInformation.interactableArea` instead of `screenInsetArea` - `interactableArea` is the HUD-safe zone (area not covered by client UI like minimap/chat); `screenInsetArea` is device-level safe margins (notch, status bar) — these are two distinct proto fields - Enables scene creators to wrap their HUD in `<InteractableArea>` and have it automatically constrained to the 75% of the screen not reserved by the Unity desktop client ## Changes - `packages/@dcl/react-ecs/src/components/InteractableArea/index.tsx` — new component (mirrors `ScreenInsetArea`) - `packages/@dcl/react-ecs/src/components/InteractableArea/types.ts` — `UiInteractableAreaProps` type - `packages/@dcl/react-ecs/src/components/utils.ts` — `getInteractableArea` / `setInteractableArea` / `resetInteractableArea` with owner-symbol reset-protection - `packages/@dcl/react-ecs/src/system.ts` — wire `canvasInfo.interactableArea` into the module variable on each frame; reset on destroy - `packages/@dcl/react-ecs/src/components/index.tsx` — export `InteractableArea` and `UiInteractableAreaProps` - `test/react-ecs/interactable-area.spec.tsx` — 4 tests mirroring `screen-inset-area.spec.tsx` ## Usage ```tsx import { InteractableArea } from '@dcl/react-ecs' <InteractableArea> <MyHud /> </InteractableArea> ``` A child sized `100%×100%` fills the interactable area exactly. On Unity desktop, `left` will be `Screen.width * 0.25` once the companion unity-explorer PR lands.
1 parent 3df0a22 commit c65544e

8 files changed

Lines changed: 277 additions & 3 deletions

File tree

packages/@dcl/playground-assets/etc/playground-assets.api.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,6 +1619,11 @@ export type InstanceCompositeOptions = {
16191619
alreadyRequestedSrc?: Set<string>;
16201620
};
16211621

1622+
// Warning: (tsdoc-undefined-tag) The TSDoc tag "@category" is not defined in this configuration
1623+
//
1624+
// @public
1625+
export function InteractableArea(props: UiInteractableAreaProps): ReactEcs.JSX.Element;
1626+
16221627
// @public (undocumented)
16231628
export const enum InteractionType {
16241629
// (undocumented)
@@ -5300,6 +5305,11 @@ export interface UiInputProps extends Omit<PBUiInput, 'font' | 'textAlign' | 'fo
53005305
// @public (undocumented)
53015306
export const UiInputResult: LastWriteWinElementSetComponentDefinition<PBUiInputResult>;
53025307

5308+
// @public
5309+
export type UiInteractableAreaProps = Omit<EntityPropTypes, 'uiTransform'> & {
5310+
uiTransform?: Omit<NonNullable<EntityPropTypes['uiTransform']>, 'positionType' | 'position'>;
5311+
};
5312+
53035313
// @public
53045314
export interface UiLabelProps {
53055315
color?: PBColor4 | undefined;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ReactEcs } from '../../react-ecs'
2+
import { UiEntity } from '../index'
3+
import { getInteractableArea } from '../utils'
4+
import { UiInteractableAreaProps } from './types'
5+
6+
/**
7+
*
8+
* @public
9+
* InteractableArea component
10+
*
11+
* Constrains its children to the area inside the renderer-reported interactable
12+
* area. This is the portion of the screen not covered by client UI such as the
13+
* minimap, chat window, or other platform overlays. On the Unity desktop client
14+
* the left 25% of the screen is reserved for client UI, so this container
15+
* positions its children within the remaining 75%.
16+
*
17+
* The container is absolutely positioned with top/left/right/bottom matching
18+
* the current `UiCanvasInformation.interactableArea`, so a child sized
19+
* 100%x100% fills the interactable area exactly.
20+
*
21+
* @example
22+
* <InteractableArea><MyHud /></InteractableArea>
23+
*
24+
* @category Component
25+
*/
26+
/* @__PURE__ */
27+
export function InteractableArea(props: UiInteractableAreaProps) {
28+
const { top, left, right, bottom } = getInteractableArea()
29+
const { uiTransform, ...otherProps } = props
30+
31+
return (
32+
<UiEntity
33+
{...otherProps}
34+
uiTransform={{
35+
...uiTransform,
36+
positionType: 'absolute',
37+
position: { top, left, right, bottom }
38+
}}
39+
/>
40+
)
41+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { EntityPropTypes } from '../types'
2+
3+
/**
4+
* InteractableArea component props
5+
*
6+
* The container reads the current `interactableArea` reported by the renderer
7+
* via `UiCanvasInformation` (the HUD-safe zone — the portion of the screen not
8+
* covered by client UI such as the minimap, chat window, or other overlays) and
9+
* constrains its children to the area inside those insets using absolute
10+
* positioning. Layout props that control the container's own position
11+
* (`positionType`, `position`) are owned by the component and are not
12+
* configurable from props — every other layout, background and event prop is
13+
* forwarded as usual.
14+
*
15+
* @public
16+
*/
17+
export type UiInteractableAreaProps = Omit<EntityPropTypes, 'uiTransform'> & {
18+
/**
19+
* Layout overrides forwarded to the underlying entity. The
20+
* `positionType` and `position` fields are reserved by the container and
21+
* any value provided here is ignored.
22+
*/
23+
uiTransform?: Omit<NonNullable<EntityPropTypes['uiTransform']>, 'positionType' | 'position'>
24+
}

packages/@dcl/react-ecs/src/components/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export * from './Dropdown/types'
1414
export * from './Label/types'
1515
export * from './Button/types'
1616
export * from './ScreenInsetArea/types'
17+
export * from './InteractableArea/types'
1718

1819
export { Dropdown } from './Dropdown'
1920
export { Input } from './Input'
2021
export { Label, scaleFontSize } from './Label'
2122
export { Button } from './Button'
2223
export { ScreenInsetArea } from './ScreenInsetArea'
24+
export { InteractableArea } from './InteractableArea'
2325

2426
/**
2527
* @public

packages/@dcl/react-ecs/src/components/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const ZERO_INSETS: BorderRect = { top: 0, left: 0, right: 0, bottom: 0 }
1818
let screenInsetArea: BorderRect = { ...ZERO_INSETS }
1919
let screenInsetAreaOwner: symbol | undefined = undefined
2020

21+
let interactableArea: BorderRect = { ...ZERO_INSETS }
22+
let interactableAreaOwner: symbol | undefined = undefined
23+
2124
/**
2225
* @internal
2326
*/
@@ -144,6 +147,44 @@ export function resetScreenInsetArea(owner?: symbol): void {
144147
screenInsetArea = { ...ZERO_INSETS }
145148
}
146149

150+
/**
151+
* @internal
152+
*/
153+
export function getInteractableArea(): BorderRect {
154+
return { ...interactableArea }
155+
}
156+
157+
/**
158+
* Sets the global interactable area.
159+
*
160+
* The `owner` symbol implements a cooperative reset-protection scheme shared
161+
* with {@link resetInteractableArea}:
162+
* - Writes always succeed — last writer claims ownership (the most recent
163+
* `owner` passed to `set` is the one allowed to `reset`).
164+
* - Resets from a non-matching owner are ignored, so a stale system can't
165+
* stomp the active area while another system is driving it.
166+
* - A reset called without an owner always wins (used by tests / teardown).
167+
*
168+
* @internal
169+
*/
170+
export function setInteractableArea(next: BorderRect, owner?: symbol): void {
171+
if (owner) {
172+
interactableAreaOwner = owner
173+
}
174+
interactableArea = { top: next.top, left: next.left, right: next.right, bottom: next.bottom }
175+
}
176+
177+
/**
178+
* @internal
179+
*/
180+
export function resetInteractableArea(owner?: symbol): void {
181+
// No-op for non-owners (see ownership rules on `setInteractableArea`).
182+
// A reset with no owner always wins — used by tests and teardown.
183+
if (owner && interactableAreaOwner !== owner) return
184+
interactableAreaOwner = undefined
185+
interactableArea = { ...ZERO_INSETS }
186+
}
187+
147188
/**
148189
* @internal
149190
*/

packages/@dcl/react-ecs/src/system.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import type { ReactEcs } from './react-ecs'
55
import { createReconciler } from './reconciler'
66
import {
77
getUiScaleFactor,
8+
resetInteractableArea,
89
resetScreenInsetArea,
910
resetUiScaleFactor,
11+
setInteractableArea,
1012
setScreenInsetArea,
1113
setUiScaleFactor
1214
} from './components/utils'
@@ -73,6 +75,8 @@ export function createReactBasedUiSystem(engine: IEngine, pointerSystem: Pointer
7375
const uiScaleFactorOwner = Symbol('react-ecs-ui-scale')
7476
// Unique owner for the screen inset module variable.
7577
const screenInsetAreaOwner = Symbol('react-ecs-screen-inset-area')
78+
// Unique owner for the interactable area module variable.
79+
const interactableAreaOwner = Symbol('react-ecs-interactable-area')
7680

7781
function getActiveVirtualSize(): UiRendererOptions | undefined {
7882
// Main renderer options win; otherwise use the first additional renderer option.
@@ -124,6 +128,11 @@ export function createReactBasedUiSystem(engine: IEngine, pointerSystem: Pointer
124128
setScreenInsetArea(canvasInfo.screenInsetArea, screenInsetAreaOwner)
125129
}
126130

131+
// Update the interactable area module variable unconditionally.
132+
if (canvasInfo?.interactableArea) {
133+
setInteractableArea(canvasInfo.interactableArea, interactableAreaOwner)
134+
}
135+
127136
const activeVirtualSize = getActiveVirtualSize()
128137
if (!activeVirtualSize) {
129138
// Reset only if this system owns the scale factor.
@@ -156,6 +165,7 @@ export function createReactBasedUiSystem(engine: IEngine, pointerSystem: Pointer
156165
engine.removeSystem(ReactBasedUiSystem)
157166
resetUiScaleFactor(uiScaleFactorOwner)
158167
resetScreenInsetArea(screenInsetAreaOwner)
168+
resetInteractableArea(interactableAreaOwner)
159169
for (const entity of renderer.getEntities()) {
160170
engine.removeEntity(entity)
161171
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Entity, YGPositionType, YGUnit } from '../../packages/@dcl/ecs/dist'
2+
import { components } from '../../packages/@dcl/ecs/src'
3+
import { InteractableArea, ReactEcs } from '../../packages/@dcl/react-ecs/src'
4+
import { CANVAS_ROOT_ENTITY } from '../../packages/@dcl/react-ecs/src/components/uiTransform'
5+
import { resetInteractableArea } from '../../packages/@dcl/react-ecs/src/components/utils'
6+
import { setupEngine } from './utils'
7+
8+
describe('InteractableArea React Ecs', () => {
9+
afterEach(() => {
10+
resetInteractableArea()
11+
})
12+
13+
it('positions itself absolutely using the renderer-reported interactable area', async () => {
14+
const { engine, uiRenderer } = setupEngine()
15+
const UiTransform = components.UiTransform(engine)
16+
const UiCanvasInformation = components.UiCanvasInformation(engine)
17+
const entityIndex = engine.addEntity() as number
18+
const rootDivEntity = (entityIndex + 1) as Entity
19+
20+
UiCanvasInformation.create(engine.RootEntity, {
21+
devicePixelRatio: 1,
22+
width: 1920,
23+
height: 1080,
24+
screenInsetArea: undefined,
25+
interactableArea: { top: 0, left: 480, right: 0, bottom: 0 }
26+
})
27+
28+
uiRenderer.setUiRenderer(() => <InteractableArea />)
29+
await engine.update(1)
30+
31+
expect(UiTransform.get(rootDivEntity)).toMatchObject({
32+
parent: CANVAS_ROOT_ENTITY,
33+
positionType: YGPositionType.YGPT_ABSOLUTE,
34+
positionTop: 0,
35+
positionLeft: 480,
36+
positionRight: 0,
37+
positionBottom: 0,
38+
positionTopUnit: YGUnit.YGU_POINT,
39+
positionLeftUnit: YGUnit.YGU_POINT,
40+
positionRightUnit: YGUnit.YGU_POINT,
41+
positionBottomUnit: YGUnit.YGU_POINT
42+
})
43+
44+
uiRenderer.destroy()
45+
})
46+
47+
it('updates its position when the interactable area changes', async () => {
48+
const { engine, uiRenderer } = setupEngine()
49+
const UiTransform = components.UiTransform(engine)
50+
const UiCanvasInformation = components.UiCanvasInformation(engine)
51+
const entityIndex = engine.addEntity() as number
52+
const rootDivEntity = (entityIndex + 1) as Entity
53+
54+
UiCanvasInformation.create(engine.RootEntity, {
55+
devicePixelRatio: 1,
56+
width: 1920,
57+
height: 1080,
58+
screenInsetArea: undefined,
59+
interactableArea: { top: 0, left: 0, right: 0, bottom: 0 }
60+
})
61+
62+
uiRenderer.setUiRenderer(() => <InteractableArea />)
63+
await engine.update(1)
64+
65+
expect(UiTransform.get(rootDivEntity)).toMatchObject({
66+
positionTop: 0,
67+
positionLeft: 0,
68+
positionRight: 0,
69+
positionBottom: 0
70+
})
71+
72+
const next = UiCanvasInformation.getMutable(engine.RootEntity)
73+
next.interactableArea = { top: 10, left: 480, right: 20, bottom: 30 }
74+
await engine.update(1)
75+
76+
expect(UiTransform.get(rootDivEntity)).toMatchObject({
77+
positionTop: 10,
78+
positionLeft: 480,
79+
positionRight: 20,
80+
positionBottom: 30
81+
})
82+
83+
uiRenderer.destroy()
84+
})
85+
86+
it('falls back to zero insets when UiCanvasInformation is not yet available', async () => {
87+
const { engine, uiRenderer } = setupEngine()
88+
const UiTransform = components.UiTransform(engine)
89+
const entityIndex = engine.addEntity() as number
90+
const rootDivEntity = (entityIndex + 1) as Entity
91+
92+
uiRenderer.setUiRenderer(() => <InteractableArea />)
93+
await engine.update(1)
94+
95+
expect(UiTransform.get(rootDivEntity)).toMatchObject({
96+
positionType: YGPositionType.YGPT_ABSOLUTE,
97+
positionTop: 0,
98+
positionLeft: 0,
99+
positionRight: 0,
100+
positionBottom: 0
101+
})
102+
103+
uiRenderer.destroy()
104+
})
105+
106+
it('forwards user uiTransform props but ignores positionType / position overrides', async () => {
107+
const { engine, uiRenderer } = setupEngine()
108+
const UiTransform = components.UiTransform(engine)
109+
const UiCanvasInformation = components.UiCanvasInformation(engine)
110+
const entityIndex = engine.addEntity() as number
111+
const rootDivEntity = (entityIndex + 1) as Entity
112+
113+
UiCanvasInformation.create(engine.RootEntity, {
114+
devicePixelRatio: 1,
115+
width: 1920,
116+
height: 1080,
117+
screenInsetArea: undefined,
118+
interactableArea: { top: 10, left: 10, right: 10, bottom: 10 }
119+
})
120+
121+
uiRenderer.setUiRenderer(() => (
122+
<InteractableArea
123+
uiTransform={
124+
{
125+
// user-provided overrides for position* should be ignored by typing,
126+
// but we cast through `any` to assert runtime behaviour as well.
127+
positionType: 'relative',
128+
position: { top: 999, left: 999, right: 999, bottom: 999 },
129+
padding: 4
130+
} as any
131+
}
132+
/>
133+
))
134+
await engine.update(1)
135+
136+
expect(UiTransform.get(rootDivEntity)).toMatchObject({
137+
positionType: YGPositionType.YGPT_ABSOLUTE,
138+
positionTop: 10,
139+
positionLeft: 10,
140+
positionRight: 10,
141+
positionBottom: 10
142+
})
143+
144+
uiRenderer.destroy()
145+
})
146+
})

test/snapshots/production-bundles/ui.ts.crdt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SCENE_COMPILED_JS_SIZE_PROD=406.3k bytes
1+
SCENE_COMPILED_JS_SIZE_PROD=406.6k bytes
22
(start empty vm 0.21.0-3680274614.commit-1808aa1)
33
OPCODES ~= 0k
44
MALLOC_COUNT = 1005
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/ui.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 88k
13-
MALLOC_COUNT = 22254
13+
MALLOC_COUNT = 22271
1414
ALIVE_OBJS_DELTA ~= 4.49k
1515
CALL onStart()
1616
OPCODES ~= 0k
@@ -66,4 +66,4 @@ CALL onUpdate(0.1)
6666
OPCODES ~= 74k
6767
MALLOC_COUNT = 0
6868
ALIVE_OBJS_DELTA ~= 0.00k
69-
MEMORY_USAGE_COUNT ~= 1890.12k bytes
69+
MEMORY_USAGE_COUNT ~= 1891.24k bytes

0 commit comments

Comments
 (0)