@dcl/react-ecs 7.25.1-31496802383.commit-d3168c9 → 7.25.1-31605440953.commit-08dff78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { ReactEcs } from '../../react-ecs';
2
2
  import { UiEntity } from '../index';
3
- import { getInteractableArea } from '../utils';
3
+ import { compensateInsetForUiScale, getInteractableArea } from '../utils';
4
4
  /**
5
5
  *
6
6
  * @public
@@ -23,7 +23,8 @@ import { getInteractableArea } from '../utils';
23
23
  */
24
24
  /* @__PURE__ */
25
25
  export function InteractableArea(props) {
26
- const { top, left, right, bottom } = getInteractableArea();
26
+ // Insets are canvas px; pre-divide so the parser's scale multiplication cancels out.
27
+ const { top, left, right, bottom } = compensateInsetForUiScale(getInteractableArea());
27
28
  const { uiTransform, ...otherProps } = props;
28
29
  return (ReactEcs.createElement(UiEntity, { ...otherProps, uiTransform: {
29
30
  ...uiTransform,
@@ -1,6 +1,6 @@
1
1
  import { ReactEcs } from '../../react-ecs';
2
2
  import { UiEntity } from '../index';
3
- import { getScreenInsetArea } from '../utils';
3
+ import { compensateInsetForUiScale, getScreenInsetArea } from '../utils';
4
4
  /**
5
5
  *
6
6
  * @public
@@ -22,7 +22,8 @@ import { getScreenInsetArea } from '../utils';
22
22
  */
23
23
  /* @__PURE__ */
24
24
  export function ScreenInsetArea(props) {
25
- const { top, left, right, bottom } = getScreenInsetArea();
25
+ // Insets are canvas px; pre-divide so the parser's scale multiplication cancels out.
26
+ const { top, left, right, bottom } = compensateInsetForUiScale(getScreenInsetArea());
26
27
  const { uiTransform, ...otherProps } = props;
27
28
  return (ReactEcs.createElement(UiEntity, { ...otherProps, uiTransform: {
28
29
  ...uiTransform,
@@ -38,8 +38,8 @@ export function getScaleAndUnit(scaleUnit) {
38
38
  /**
39
39
  * @internal
40
40
  */
41
- export function scaleOnDim(scale, dim, pxRatio) {
42
- return (dim / 100) * (scale / pxRatio);
41
+ export function scaleOnDim(scale, dim) {
42
+ return (dim / 100) * scale;
43
43
  }
44
44
  /**
45
45
  * @internal
@@ -90,6 +90,21 @@ export function resetUiScaleFactor(owner) {
90
90
  uiScaleOwner = undefined;
91
91
  uiScaleFactor = 1;
92
92
  }
93
+ /**
94
+ * Divides an inset area by the current UI scale factor.
95
+ *
96
+ * Inset areas are reported by the renderer in canvas pixels, but raw pixel
97
+ * values in `uiTransform` props are multiplied by the UI scale factor when
98
+ * parsed. Pre-dividing cancels that multiplication out, so the values sent to
99
+ * the renderer stay in canvas pixels regardless of the virtual screen.
100
+ *
101
+ * @internal
102
+ */
103
+ export function compensateInsetForUiScale(area) {
104
+ const scale = getUiScaleFactor();
105
+ const factor = scale > 0 ? scale : 1;
106
+ return { top: area.top / factor, left: area.left / factor, right: area.right / factor, bottom: area.bottom / factor };
107
+ }
93
108
  /**
94
109
  * @internal
95
110
  */
@@ -169,9 +184,12 @@ export function calcOnViewport(value, ctx = getScaleCtx()) {
169
184
  const [scale, unit] = getScaleAndUnit(value);
170
185
  if (!ctx)
171
186
  return scale;
172
- const { height, width, ratio } = ctx;
187
+ // `ctx.ratio` is intentionally not read: '1vw' is 1% of the canvas width by
188
+ // definition, exactly as in CSS. It stays on ScaleContext as an informational
189
+ // density hint for callers that need to pick an asset resolution.
190
+ const { height, width } = ctx;
173
191
  if (unit === 'vh')
174
- return scaleOnDim(scale, height, ratio);
192
+ return scaleOnDim(scale, height);
175
193
  // by default, we scale by 'vw' (width)
176
- return scaleOnDim(scale, width, ratio);
194
+ return scaleOnDim(scale, width);
177
195
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Injects the function used to detect whether the scene runs on a mobile
3
+ * platform. Called by `@dcl/sdk` with its `isMobile()` platform helper.
4
+ */
5
+ export declare function setIsMobileProvider(provider: () => boolean): void;
6
+ /**
7
+ * Whether the scene is running on a mobile platform, according to the
8
+ * injected provider. Platform detection is asynchronous on the SDK side, so
9
+ * this may return false during the first ticks and flip to true once the
10
+ * explorer information arrives.
11
+ */
12
+ export declare function isMobile(): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Platform detection hook for the UI scale system.
3
+ *
4
+ * react-ecs stays independent of the scene runtime (`~system/*` modules), so
5
+ * it cannot ask the explorer which platform it runs on. Instead, the host SDK
6
+ * injects the check at module-load time (see `@dcl/sdk/react-ecs`). Until a
7
+ * provider is injected — or when none ever is — the platform is assumed to be
8
+ * non-mobile.
9
+ */
10
+ let isMobileProvider = () => false;
11
+ /**
12
+ * Injects the function used to detect whether the scene runs on a mobile
13
+ * platform. Called by `@dcl/sdk` with its `isMobile()` platform helper.
14
+ */
15
+ export function setIsMobileProvider(provider) {
16
+ isMobileProvider = provider;
17
+ }
18
+ /**
19
+ * Whether the scene is running on a mobile platform, according to the
20
+ * injected provider. Platform detection is asynchronous on the SDK side, so
21
+ * this may return false during the first ticks and flip to true once the
22
+ * explorer information arrives.
23
+ */
24
+ export function isMobile() {
25
+ return isMobileProvider();
26
+ }
package/dist/system.d.ts CHANGED
@@ -4,12 +4,29 @@ import type { ReactEcs } from './react-ecs';
4
4
  * @public
5
5
  */
6
6
  export type UiComponent = () => ReactEcs.JSX.ReactNode;
7
+ /**
8
+ * Screen area used to position a renderer's UI entities:
9
+ * - `'device'` (default): the device safe area (excludes notch, status bar,
10
+ * rounded corners), reported in `UiCanvasInformation.screenInsetArea`.
11
+ * - `'interactable'`: the area free of the Explorer's native HUD (minimap, chat, ...),
12
+ * reported in `UiCanvasInformation.interactableArea`.
13
+ * - `'none'`: the whole screen, with 0,0 at its top-left corner.
14
+ * @public
15
+ */
16
+ export type UiScreenInset = 'device' | 'interactable' | 'none';
7
17
  /**
8
18
  * @public
9
19
  */
10
20
  export type UiRendererOptions = {
11
- virtualWidth: number;
12
- virtualHeight: number;
21
+ virtualWidth?: number;
22
+ virtualHeight?: number;
23
+ /**
24
+ * Screen area the renderer's UI is positioned in. Defaults to `'device'`, so UI
25
+ * stays inside the device safe area unless the creator opts out with `'none'`.
26
+ * Each renderer honors its own value, so the main UI and additional renderers
27
+ * can use different insets simultaneously.
28
+ */
29
+ screenInset?: UiScreenInset;
13
30
  };
14
31
  /**
15
32
  * @public
@@ -21,6 +38,16 @@ export interface ReactBasedUiSystem {
21
38
  destroy(): void;
22
39
  /**
23
40
  * Set the main UI renderer. Optional virtual size defines the global UI scale factor.
41
+ *
42
+ * When no virtual size is provided, a platform default is used: 1600x720 on
43
+ * mobile, 1920x1080 otherwise. Providing an invalid size disables the virtual
44
+ * screen (no UI scaling): either a value \<= 0, or only one of the two
45
+ * dimensions, which additionally logs a warning. On mobile, a provided 16:9
46
+ * virtual size is overridden to 1600x720 to fit phone screens.
47
+ *
48
+ * The optional `screenInset` selects the screen area the UI is positioned in
49
+ * (see {@link UiScreenInset}); it defaults to `'device'`. Pass `'none'` to
50
+ * place the UI over the whole screen.
24
51
  */
25
52
  setUiRenderer(ui: UiComponent, options?: UiRendererOptions): void;
26
53
  /**
@@ -35,7 +62,10 @@ export interface ReactBasedUiSystem {
35
62
  * @param entity - The entity to associate with this UI renderer. When the entity is removed,
36
63
  * the UI renderer is automatically cleaned up.
37
64
  * @param ui - The UI component to render
38
- * @param options - Optional virtual size used for UI scale factor when main UI has none
65
+ * @param options - Optional virtual size used for UI scale factor when main UI has none.
66
+ * Defaults and the mobile 16:9 override behave as in {@link ReactBasedUiSystem.setUiRenderer}.
67
+ * `screenInset` is honored per renderer, independently of the main UI's value,
68
+ * and defaults to `'device'` here too.
39
69
  */
40
70
  addUiRenderer(entity: Entity, ui: UiComponent, options?: UiRendererOptions): void;
41
71
  /**
package/dist/system.js CHANGED
@@ -3,13 +3,49 @@ import * as ecsComponents from '@dcl/ecs/dist/components';
3
3
  import React from 'react';
4
4
  import { createReconciler } from './reconciler';
5
5
  import { getUiScaleFactor, resetInteractableArea, resetScreenInsetArea, resetUiScaleFactor, setInteractableArea, setScreenInsetArea, setUiScaleFactor } from './components/utils';
6
+ import { InteractableArea, ScreenInsetArea } from './components';
7
+ import { isMobile } from './platform';
8
+ /**
9
+ * Default virtual screen size used on mobile platforms, and the size 16:9
10
+ * virtual screens are overridden to on mobile (phone screens are much wider
11
+ * than 16:9, so a 16:9 virtual canvas would letterbox the UI).
12
+ */
13
+ const DEFAULT_MOBILE_VIRTUAL_SIZE = { virtualWidth: 1600, virtualHeight: 720 };
14
+ /**
15
+ * Default virtual screen size used on non-mobile platforms.
16
+ */
17
+ const DEFAULT_VIRTUAL_SIZE = { virtualWidth: 1920, virtualHeight: 1080 };
18
+ /**
19
+ * Screen area a renderer uses when it doesn't pick one. UI defaults to the device
20
+ * safe area: drawing under a notch, a status bar or a rounded corner is something
21
+ * a creator should opt into, not the out-of-the-box behavior.
22
+ */
23
+ const DEFAULT_SCREEN_INSET = 'device';
24
+ function hasVirtualSize(options) {
25
+ return !!options && (options.virtualWidth !== undefined || options.virtualHeight !== undefined);
26
+ }
27
+ function isValidVirtualSize(options) {
28
+ return !!options && (options.virtualWidth ?? 0) > 0 && (options.virtualHeight ?? 0) > 0;
29
+ }
30
+ /**
31
+ * Whether a provided size spells out one dimension but not the other. Both are
32
+ * optional in the type, so this is reachable. It is invalid either way — this only
33
+ * decides whether to warn, since a half-given size is a mistake while a value
34
+ * \<= 0 is the documented way to turn the virtual screen off.
35
+ */
36
+ function isPartialVirtualSize(options) {
37
+ return (options.virtualWidth === undefined) !== (options.virtualHeight === undefined);
38
+ }
39
+ function is16by9(options) {
40
+ return options.virtualWidth * 9 === options.virtualHeight * 16;
41
+ }
6
42
  /**
7
43
  * @public
8
44
  */
9
45
  export function createReactBasedUiSystem(engine, pointerSystem) {
10
46
  const renderer = createReconciler(engine, pointerSystem);
11
47
  let uiComponent = undefined;
12
- let virtualSize = undefined;
48
+ let mainOptions = undefined;
13
49
  const additionalRenderers = new Map();
14
50
  const UiCanvasInformation = ecsComponents.UiCanvasInformation(engine);
15
51
  // Unique owner to prevent other UI systems resetting this scale factor.
@@ -18,21 +54,92 @@ export function createReactBasedUiSystem(engine, pointerSystem) {
18
54
  const screenInsetAreaOwner = Symbol('react-ecs-screen-inset-area');
19
55
  // Unique owner for the interactable area module variable.
20
56
  const interactableAreaOwner = Symbol('react-ecs-interactable-area');
57
+ // Last 16:9 size we already logged the mobile override for, so the log
58
+ // fires once per provided size instead of every tick. Tracked as raw numbers
59
+ // to avoid allocating a comparison string every tick.
60
+ let loggedMobileOverrideW = 0;
61
+ let loggedMobileOverrideH = 0;
62
+ // Same once-per-size guard for the incomplete-size warning. A partial size maps
63
+ // its missing dimension to 0, and the provided one can itself be 0, so -1 is the
64
+ // "nothing logged yet" sentinel — 0/0 is a reachable real value here.
65
+ let loggedPartialW = -1;
66
+ let loggedPartialH = -1;
21
67
  function getActiveVirtualSize() {
22
68
  // Main renderer options win; otherwise use the first additional renderer option.
23
- if (virtualSize)
24
- return virtualSize;
69
+ // Options carrying no virtual dims (e.g. only a screen inset) are skipped so
70
+ // they don't count as a provided-but-invalid virtual size.
71
+ if (hasVirtualSize(mainOptions))
72
+ return mainOptions;
25
73
  for (const entry of additionalRenderers.values()) {
26
- if (entry.options)
74
+ if (hasVirtualSize(entry.options))
27
75
  return entry.options;
28
76
  }
29
77
  return undefined;
30
78
  }
79
+ /**
80
+ * Resolves the virtual screen to scale the UI against, or `undefined` when
81
+ * the virtual screen is disabled.
82
+ */
83
+ function resolveVirtualSize() {
84
+ const provided = getActiveVirtualSize();
85
+ const mobile = isMobile();
86
+ // No creator-provided size: fall back to the platform default.
87
+ if (!provided) {
88
+ return mobile ? DEFAULT_MOBILE_VIRTUAL_SIZE : DEFAULT_VIRTUAL_SIZE;
89
+ }
90
+ // An explicitly provided but invalid size disables the virtual screen — no UI
91
+ // scaling at all. That covers a value <= 0 (the deliberate opt-out) and a size
92
+ // that gives only one of its two dimensions.
93
+ if (!isValidVirtualSize(provided)) {
94
+ // A half-given size is a mistake rather than an opt-out, and disabling scaling
95
+ // is not what the creator was reaching for, so say so once per provided size.
96
+ // The <= 0 opt-out is documented and stays silent.
97
+ if (isPartialVirtualSize(provided)) {
98
+ const width = provided.virtualWidth ?? 0;
99
+ const height = provided.virtualHeight ?? 0;
100
+ if (loggedPartialW !== width || loggedPartialH !== height) {
101
+ loggedPartialW = width;
102
+ loggedPartialH = height;
103
+ console.log(`Incomplete virtual screen size (virtualWidth: ${provided.virtualWidth}, virtualHeight: ${provided.virtualHeight}): both dimensions are required, so the virtual screen is disabled and no UI scaling is applied.`);
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ // On mobile, 16:9 virtual screens don't fit phone aspect ratios — override them.
109
+ if (mobile && is16by9(provided)) {
110
+ if (loggedMobileOverrideW !== provided.virtualWidth || loggedMobileOverrideH !== provided.virtualHeight) {
111
+ loggedMobileOverrideW = provided.virtualWidth;
112
+ loggedMobileOverrideH = provided.virtualHeight;
113
+ console.log(`Mobile platform detected: overriding 16:9 virtual screen size ${provided.virtualWidth}x${provided.virtualHeight} with ${DEFAULT_MOBILE_VIRTUAL_SIZE.virtualWidth}x${DEFAULT_MOBILE_VIRTUAL_SIZE.virtualHeight}`);
114
+ }
115
+ return DEFAULT_MOBILE_VIRTUAL_SIZE;
116
+ }
117
+ return provided;
118
+ }
119
+ /**
120
+ * Wraps a renderer's component in a container positioned within the selected
121
+ * screen inset area. `'none'` adds no wrapper, leaving the UI on the whole
122
+ * screen. Applied per renderer, so each renderer can use a different inset.
123
+ */
124
+ function wrapWithScreenInset(ui, inset, key) {
125
+ // An omitted inset is resolved to DEFAULT_SCREEN_INSET ('device') before the
126
+ // switch, so it lands on `case 'device'`. Only an explicit 'none' reaches the
127
+ // `default` clause below — the two are unrelated despite sharing a name.
128
+ switch (inset ?? DEFAULT_SCREEN_INSET) {
129
+ case 'device':
130
+ return React.createElement(ScreenInsetArea, { key }, React.createElement(ui));
131
+ case 'interactable':
132
+ return React.createElement(InteractableArea, { key }, React.createElement(ui));
133
+ // 'none' — the whole screen, no wrapper entity
134
+ default:
135
+ return React.createElement(ui, { key });
136
+ }
137
+ }
31
138
  function ReactBasedUiSystem() {
32
139
  const components = [];
33
140
  // Add main UI component
34
141
  if (uiComponent) {
35
- components.push(React.createElement(uiComponent, { key: '__main__' }));
142
+ components.push(wrapWithScreenInset(uiComponent, mainOptions?.screenInset, '__main__'));
36
143
  }
37
144
  const entitiesToRemove = [];
38
145
  for (const [entity, entry] of additionalRenderers) {
@@ -41,7 +148,7 @@ export function createReactBasedUiSystem(engine, pointerSystem) {
41
148
  entitiesToRemove.push(entity);
42
149
  }
43
150
  else {
44
- components.push(React.createElement(entry.ui, { key: `__entity_${entity}__` }));
151
+ components.push(wrapWithScreenInset(entry.ui, entry.options?.screenInset, `__entity_${entity}__`));
45
152
  }
46
153
  }
47
154
  // Entity-based cleanup
@@ -68,22 +175,33 @@ export function createReactBasedUiSystem(engine, pointerSystem) {
68
175
  if (canvasInfo?.interactableArea) {
69
176
  setInteractableArea(canvasInfo.interactableArea, interactableAreaOwner);
70
177
  }
71
- const activeVirtualSize = getActiveVirtualSize();
72
- if (!activeVirtualSize) {
178
+ // The virtual screen (provided or defaulted) only applies while some
179
+ // renderer is registered; with no UI at all the scale factor is released.
180
+ if (uiComponent === undefined && additionalRenderers.size === 0) {
73
181
  // Reset only if this system owns the scale factor.
74
182
  resetUiScaleFactor(uiScaleFactorOwner);
75
183
  return;
76
184
  }
185
+ const activeVirtualSize = resolveVirtualSize();
186
+ if (!activeVirtualSize) {
187
+ // Virtual screen explicitly disabled by an invalid provided size.
188
+ resetUiScaleFactor(uiScaleFactorOwner);
189
+ return;
190
+ }
77
191
  if (!canvasInfo)
78
192
  return;
79
- const { width, height, devicePixelRatio } = canvasInfo;
193
+ const { width, height } = canvasInfo;
80
194
  const { virtualWidth, virtualHeight } = activeVirtualSize;
81
- if (!virtualWidth || !virtualHeight)
82
- return;
83
- // Normalize by devicePixelRatio so virtual px map to logical px (matching the
84
- // vw/vh path); without it the scale was inflated on high-dpr mobile screens.
85
- const ratio = devicePixelRatio || 1;
86
- const nextScale = Math.min(width / virtualWidth, height / virtualHeight) / ratio;
195
+ // The scale factor is the contain-fit of the design resolution inside the canvas,
196
+ // and nothing else.
197
+ //
198
+ // devicePixelRatio is deliberately absent. It is a density hint "how many physical
199
+ // pixels per canvas unit", for picking a 1x/2x/3x asset — not a layout unit, the same
200
+ // role it has in CSS and React Native, where it is exposed but never enters layout.
201
+ // Dividing by it made UI size inversely proportional to a quantity the scene author
202
+ // does not control and that measures something different on every renderer: panel
203
+ // density on mobile, OS display scaling on web, display/window on native desktop.
204
+ const nextScale = Math.min(width / virtualWidth, height / virtualHeight);
87
205
  if (Number.isFinite(nextScale) && nextScale !== getUiScaleFactor()) {
88
206
  // Track ownership when updating to avoid cross-system conflicts.
89
207
  setUiScaleFactor(nextScale, uiScaleFactorOwner);
@@ -104,7 +222,7 @@ export function createReactBasedUiSystem(engine, pointerSystem) {
104
222
  },
105
223
  setUiRenderer(ui, options) {
106
224
  uiComponent = ui;
107
- virtualSize = options;
225
+ mainOptions = options;
108
226
  },
109
227
  addUiRenderer(entity, ui, options) {
110
228
  additionalRenderers.set(entity, { ui, options });
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dcl/react-ecs",
3
3
  "description": "Decentraland ECS",
4
- "version": "7.25.1-31496802383.commit-d3168c9",
4
+ "version": "7.25.1-31605440953.commit-08dff78",
5
5
  "author": "DCL",
6
6
  "bugs": "https://github.com/decentraland/js-sdk-toolchain/issues",
7
7
  "dependencies": {
8
- "@dcl/ecs": "7.25.1-31496802383.commit-d3168c9",
8
+ "@dcl/ecs": "7.25.1-31605440953.commit-08dff78",
9
9
  "react": "^18.2.0",
10
10
  "react-reconciler": "^0.29.0"
11
11
  },
@@ -40,5 +40,5 @@
40
40
  "tsconfig": "./tsconfig.json"
41
41
  },
42
42
  "types": "./dist/index.d.ts",
43
- "commit": "d3168c9833ced3a80eba5de83601e5dff7f00a75"
43
+ "commit": "08dff7864080ec5e4a06b533827c69799774a2f2"
44
44
  }