From db9630c7be28f4b4a081849bb812d2ef2f176a16 Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Wed, 3 Jun 2026 16:43:54 +0200 Subject: [PATCH] Add viewport safe-area API for play overlays --- .../packages/editor-oss/src/EngineRuntime.ts | 17 +++ .../src/behaviors/game/GameManager.ts | 5 + .../src/behaviors/stem/StemEngineInterface.ts | 11 ++ .../stem/createStemEngineInterface.ts | 6 + .../v2/BehaviorEditor/types/behavior.d.ts | 24 +++ .../src/utils/viewportSafeArea.test.ts | 106 ++++++++++++++ .../editor-oss/src/utils/viewportSafeArea.ts | 137 ++++++++++++++++++ .../src/player/component/PlayerWatermark.tsx | 27 +++- .../planning/2026-06-03-viewport-safe-area.md | 31 ++++ docs/runtime-api.md | 14 ++ 10 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 client/packages/editor-oss/src/utils/viewportSafeArea.test.ts create mode 100644 client/packages/editor-oss/src/utils/viewportSafeArea.ts create mode 100644 docs/planning/2026-06-03-viewport-safe-area.md diff --git a/client/packages/editor-oss/src/EngineRuntime.ts b/client/packages/editor-oss/src/EngineRuntime.ts index 8a9c3050..069a6160 100644 --- a/client/packages/editor-oss/src/EngineRuntime.ts +++ b/client/packages/editor-oss/src/EngineRuntime.ts @@ -83,6 +83,7 @@ import type {RamPanelManager} from "./utils/RamPanelManager"; import {SceneLoadProfiler} from "./utils/SceneLoadProfiler"; import {findObjectsInRectangle} from "./utils/SelectionUtils"; import Storage from "./utils/Storage"; +import {getViewportSafeArea, type ViewportSafeArea} from "./utils/viewportSafeArea"; // TODO: Move RectAreaLightTexturesLib initialization to appropriate place RectAreaLightNode.setLTC(RectAreaLightTexturesLib.init()); @@ -110,6 +111,7 @@ export const BILLBOARD_BEHAVIOR_ID = "billboard"; export const CHARACTER_BEHAVIOR_ID = "character"; export const ENEMY_BEHAVIOR_ID = "enemy"; export const NPC_BEHAVIOR_ID = "npc"; +export type {ViewportSafeArea} from "./utils/viewportSafeArea"; // Application have a lot of responsibilities, which can lead to high complexity and low maintainability. // Consider splitting responsibilities to different classes or modules (e.g., SceneManager, ModeManager, etc.) @@ -118,6 +120,8 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { return window.location.pathname.indexOf("/sandbox/") !== -1; } + private viewportSafeAreaElements = new Map(); + // Make sure that we have clear interfaces instead of using field directly to assign values // This will help reduce complexity and improve maintainability @@ -216,6 +220,19 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { get playerEvent(): PlayerEvent | null { return this.playerSession?.playerEvent ?? null; } + + getViewportSafeArea(): ViewportSafeArea { + return getViewportSafeArea(this.viewport, Array.from(this.viewportSafeAreaElements.values())); + } + + registerViewportSafeAreaElement(id: string, element: HTMLElement | null): void { + if (!id) return; + if (element) { + this.viewportSafeAreaElements.set(id, element); + return; + } + this.viewportSafeAreaElements.delete(id); + } get aiWorldControl(): AiWorldControl | null { return this.playerSession?.aiWorldControl ?? null; } diff --git a/client/packages/editor-oss/src/behaviors/game/GameManager.ts b/client/packages/editor-oss/src/behaviors/game/GameManager.ts index db15ce61..45745a32 100644 --- a/client/packages/editor-oss/src/behaviors/game/GameManager.ts +++ b/client/packages/editor-oss/src/behaviors/game/GameManager.ts @@ -90,6 +90,7 @@ import AIConversationManager from "../packs/aiNpc/AiConversationManager"; import {IMultiplayerState} from "../state/IMultiplayerState"; import UIKitPointerEvents from "../uikit/UIKitPointerEvents"; import {isLegacyBehaviorId} from "../util"; +import type {ViewportSafeArea} from "../../utils/viewportSafeArea"; export interface IControl { attachPlayerObject(player: Object3D, characterOptions: CharacterOptionsInterface): Promise; @@ -205,6 +206,10 @@ class GameManager { } } + public getViewportSafeArea(): ViewportSafeArea { + return this.engine.getViewportSafeArea(); + } + /** * Handle unified game services authentication success * @param user - Authenticated user from unified game services diff --git a/client/packages/editor-oss/src/behaviors/stem/StemEngineInterface.ts b/client/packages/editor-oss/src/behaviors/stem/StemEngineInterface.ts index 708a19e8..b6ae2aa2 100644 --- a/client/packages/editor-oss/src/behaviors/stem/StemEngineInterface.ts +++ b/client/packages/editor-oss/src/behaviors/stem/StemEngineInterface.ts @@ -16,6 +16,15 @@ import { StemStore } from './store/StemStore'; import { StemTeam } from './team/StemTeam'; import { StemTween } from './tween/StemTween'; import type { Lambda } from '../../lambdas/Lambda'; +import type {ViewportSafeArea} from '../../utils/viewportSafeArea'; + +export interface StemViewport { + /** + * Safe viewport rect in window coordinates. + * Useful for DOM overlays or UI projection that must avoid host chrome. + */ + getSafeArea(): ViewportSafeArea; +} /** * Lambda ECS system access for behaviors. @@ -133,6 +142,8 @@ export interface StemEngineInterface { camera: StemCamera; /** 3D object creation from Three.js objects. */ object: StemObject; + /** Measured visible runtime viewport for overlay-safe DOM and screen-space UI. */ + viewport: StemViewport; /** Scene graph manipulation (adding objects). */ scene: StemScene; /** diff --git a/client/packages/editor-oss/src/behaviors/stem/createStemEngineInterface.ts b/client/packages/editor-oss/src/behaviors/stem/createStemEngineInterface.ts index 83e2b612..564d6726 100644 --- a/client/packages/editor-oss/src/behaviors/stem/createStemEngineInterface.ts +++ b/client/packages/editor-oss/src/behaviors/stem/createStemEngineInterface.ts @@ -23,6 +23,10 @@ import EngineRuntime from "@stem/editor-oss/EngineRuntime"; import { createForeignLambdaView } from "../../lambdas/Lambda"; import GameManager from "../game/GameManager"; +const createViewportInterface = (game: GameManager) => ({ + getSafeArea: () => game.getViewportSafeArea(), +}); + const createLambdasInterface = (game: GameManager): StemLambdas => { return { getInstance: (instanceId: string) => { @@ -66,6 +70,7 @@ export const createStemEngineInterface = (game: GameManager, globalStore: Global team: createTeamInterface(), pool: createPoolInterface(), object: createObjectInterface(game), + viewport: createViewportInterface(game), scene: createSceneInterface(game), store: createStoreInterface(globalStore), lambdas: createLambdasInterface(game), @@ -92,6 +97,7 @@ export const createEditorErthInterface = (engine: EngineRuntime): StemEngineInte ai: { generate: () => notAvailable('ai') } as any, camera: { setTarget: () => notAvailable('camera'), getPosition: () => notAvailable('camera') } as any, object: { create: () => notAvailable('object'), destroy: () => notAvailable('object') } as any, + viewport: { getSafeArea: () => engine.getViewportSafeArea() }, scene: { getObjects: () => notAvailable('scene') } as any, lambdas: { getInstance: () => notAvailable('lambdas') } as any, behaviors: { find: () => notAvailable('behaviors') } as any, diff --git a/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/types/behavior.d.ts b/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/types/behavior.d.ts index e92adce1..747e42a9 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/types/behavior.d.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/types/behavior.d.ts @@ -65,6 +65,24 @@ interface StemStore { readonly size: number; } +interface StemViewportSafeArea { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + insetLeft: number; + insetTop: number; + insetRight: number; + insetBottom: number; +} + +interface StemViewport { + /** Safe runtime viewport bounds in window coordinates. */ + getSafeArea(): StemViewportSafeArea; +} + /** * Erth interface providing access to game subsystems. * Access via this.erth in behavior scripts. @@ -86,6 +104,9 @@ interface StemEngineInterface { */ store: StemStore; + /** Measured visible runtime viewport for overlay-safe DOM and screen-space UI. */ + viewport: StemViewport; + /** Asset subsystem for loading images, audio, video, and models from the asset library */ asset: { image: { @@ -361,6 +382,9 @@ interface GameManager { /** Check if game is started */ isGameStarted(): boolean; + /** Safe runtime viewport bounds in window coordinates. */ + getViewportSafeArea(): StemViewportSafeArea; + /** Add object to scene */ addObject(object: THREE.Object3D, parent?: THREE.Object3D): Promise; /** Remove object from scene */ diff --git a/client/packages/editor-oss/src/utils/viewportSafeArea.test.ts b/client/packages/editor-oss/src/utils/viewportSafeArea.test.ts new file mode 100644 index 00000000..bcf23ace --- /dev/null +++ b/client/packages/editor-oss/src/utils/viewportSafeArea.test.ts @@ -0,0 +1,106 @@ +import {describe, expect, it} from "vitest"; + +import {measureViewportSafeArea} from "./viewportSafeArea"; + +describe("measureViewportSafeArea", () => { + it("falls back to the full window when no viewport is available", () => { + expect(measureViewportSafeArea({windowWidth: 1280, windowHeight: 720})).toEqual({ + left: 0, + top: 0, + right: 1280, + bottom: 720, + width: 1280, + height: 720, + insetLeft: 0, + insetTop: 0, + insetRight: 0, + insetBottom: 0, + }); + }); + + it("derives safe insets from the viewport rect", () => { + expect(measureViewportSafeArea({ + windowWidth: 1280, + windowHeight: 720, + rect: { + left: 24, + top: 48, + width: 1200, + height: 640, + right: 1224, + bottom: 688, + }, + })).toEqual({ + left: 24, + top: 48, + right: 1224, + bottom: 688, + width: 1200, + height: 640, + insetLeft: 24, + insetTop: 48, + insetRight: 56, + insetBottom: 32, + }); + }); + + it("clamps an oversized rect back into the window bounds", () => { + expect(measureViewportSafeArea({ + windowWidth: 1000, + windowHeight: 600, + rect: { + left: -20, + top: 40, + width: 1100, + height: 700, + right: 1080, + bottom: 740, + }, + })).toEqual({ + left: 0, + top: 40, + right: 1000, + bottom: 600, + width: 1000, + height: 560, + insetLeft: 0, + insetTop: 40, + insetRight: 0, + insetBottom: 0, + }); + }); + + it("shrinks the safe area for edge-anchored host overlays inside the viewport", () => { + expect(measureViewportSafeArea({ + windowWidth: 1280, + windowHeight: 720, + rect: { + left: 0, + top: 48, + width: 1280, + height: 672, + right: 1280, + bottom: 720, + }, + occluders: [{ + left: 1160, + top: 676, + width: 108, + height: 32, + right: 1268, + bottom: 708, + }], + })).toEqual({ + left: 0, + top: 48, + right: 1160, + bottom: 676, + width: 1160, + height: 628, + insetLeft: 0, + insetTop: 48, + insetRight: 120, + insetBottom: 44, + }); + }); +}); \ No newline at end of file diff --git a/client/packages/editor-oss/src/utils/viewportSafeArea.ts b/client/packages/editor-oss/src/utils/viewportSafeArea.ts new file mode 100644 index 00000000..7b092b4f --- /dev/null +++ b/client/packages/editor-oss/src/utils/viewportSafeArea.ts @@ -0,0 +1,137 @@ +export interface ViewportSafeArea { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + insetLeft: number; + insetTop: number; + insetRight: number; + insetBottom: number; +} + +interface RectLike { + left: number; + top: number; + right?: number; + bottom?: number; + width: number; + height: number; +} + +interface MeasureViewportSafeAreaOptions { + windowWidth: number; + windowHeight: number; + rect?: RectLike | null; + occluders?: Array; +} + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); +const EDGE_THRESHOLD_PX = 16; + +const getEdge = (primary: number | undefined, fallback: number) => + Number.isFinite(primary) ? (primary as number) : fallback; + +export const measureViewportSafeArea = ({ + windowWidth, + windowHeight, + rect, + occluders = [], +}: MeasureViewportSafeAreaOptions): ViewportSafeArea => { + const safeWindowWidth = Math.max(0, Number.isFinite(windowWidth) ? windowWidth : 0); + const safeWindowHeight = Math.max(0, Number.isFinite(windowHeight) ? windowHeight : 0); + + if (!rect) { + return { + left: 0, + top: 0, + right: safeWindowWidth, + bottom: safeWindowHeight, + width: safeWindowWidth, + height: safeWindowHeight, + insetLeft: 0, + insetTop: 0, + insetRight: 0, + insetBottom: 0, + }; + } + + const rawLeft = Number.isFinite(rect.left) ? rect.left : 0; + const rawTop = Number.isFinite(rect.top) ? rect.top : 0; + const rawRight = getEdge(rect.right, rawLeft + (Number.isFinite(rect.width) ? rect.width : 0)); + const rawBottom = getEdge(rect.bottom, rawTop + (Number.isFinite(rect.height) ? rect.height : 0)); + + const left = clamp(rawLeft, 0, safeWindowWidth); + const top = clamp(rawTop, 0, safeWindowHeight); + const right = clamp(rawRight, 0, safeWindowWidth); + const bottom = clamp(rawBottom, 0, safeWindowHeight); + const baseWidth = Math.max(0, right - left); + const baseHeight = Math.max(0, bottom - top); + + let insetLeft = left; + let insetTop = top; + let insetRight = Math.max(0, safeWindowWidth - right); + let insetBottom = Math.max(0, safeWindowHeight - bottom); + + occluders.forEach(occluder => { + if (!occluder) return; + + const occluderLeft = clamp(occluder.left, 0, safeWindowWidth); + const occluderTop = clamp(occluder.top, 0, safeWindowHeight); + const occluderRight = clamp(getEdge(occluder.right, occluder.left + occluder.width), 0, safeWindowWidth); + const occluderBottom = clamp(getEdge(occluder.bottom, occluder.top + occluder.height), 0, safeWindowHeight); + + if (occluderRight <= left || occluderLeft >= right || occluderBottom <= top || occluderTop >= bottom) { + return; + } + + if (occluderTop - top <= EDGE_THRESHOLD_PX) { + insetTop = Math.max(insetTop, Math.max(0, occluderBottom - top) + top); + } + if (bottom - occluderBottom <= EDGE_THRESHOLD_PX) { + insetBottom = Math.max(insetBottom, Math.max(0, bottom - occluderTop) + (safeWindowHeight - bottom)); + } + if (occluderLeft - left <= EDGE_THRESHOLD_PX) { + insetLeft = Math.max(insetLeft, Math.max(0, occluderRight - left) + left); + } + if (right - occluderRight <= EDGE_THRESHOLD_PX) { + insetRight = Math.max(insetRight, Math.max(0, right - occluderLeft) + (safeWindowWidth - right)); + } + }); + + const safeLeft = insetLeft; + const safeTop = insetTop; + const safeRight = Math.max(safeLeft, safeWindowWidth - insetRight); + const safeBottom = Math.max(safeTop, safeWindowHeight - insetBottom); + const width = Math.max(0, Math.min(baseWidth, safeRight - safeLeft)); + const height = Math.max(0, Math.min(baseHeight, safeBottom - safeTop)); + + return { + left: safeLeft, + top: safeTop, + right: safeLeft + width, + bottom: safeTop + height, + width, + height, + insetLeft, + insetTop, + insetRight, + insetBottom, + }; +}; + +export const getViewportSafeArea = ( + viewport?: HTMLElement | null, + occluders: Array = [], +): ViewportSafeArea => { + const windowWidth = window.innerWidth || document.documentElement?.clientWidth || 0; + const windowHeight = window.innerHeight || document.documentElement?.clientHeight || 0; + + return measureViewportSafeArea({ + windowWidth, + windowHeight, + rect: viewport?.getBoundingClientRect(), + occluders: occluders.map(occluder => occluder?.getBoundingClientRect()), + }); +}; \ No newline at end of file diff --git a/client/packages/shared/src/player/component/PlayerWatermark.tsx b/client/packages/shared/src/player/component/PlayerWatermark.tsx index 7206a2a1..e9ca1569 100644 --- a/client/packages/shared/src/player/component/PlayerWatermark.tsx +++ b/client/packages/shared/src/player/component/PlayerWatermark.tsx @@ -1,5 +1,9 @@ +import {useEffect, useRef} from "react"; import styled from "styled-components"; +import type EngineRuntime from "../../EngineRuntime"; +import global from "../../global"; + const Bottom = styled.nav` position: fixed; z-index: 2099; @@ -23,8 +27,21 @@ const Wordmark = styled.div` } `; -export const PlayerWatermark = () => ( - - Stem Studio - -); +export const PlayerWatermark = () => { + const rootRef = useRef(null); + + useEffect(() => { + const app = global.app as EngineRuntime | undefined; + app?.registerViewportSafeAreaElement("player-watermark", rootRef.current); + + return () => { + app?.registerViewportSafeAreaElement("player-watermark", null); + }; + }, []); + + return ( + + Stem Studio + + ); +}; diff --git a/docs/planning/2026-06-03-viewport-safe-area.md b/docs/planning/2026-06-03-viewport-safe-area.md new file mode 100644 index 00000000..cd211b64 --- /dev/null +++ b/docs/planning/2026-06-03-viewport-safe-area.md @@ -0,0 +1,31 @@ +# Viewport Safe Area API + +## Goal + +- [x] Expose a runtime safe-area API derived from the actual play viewport so behavior DOM and screen-space UI can avoid StemStudio chrome. + +## Assumptions + +- [x] The play viewport element is already the source of truth for the unobscured runtime area. +- [x] Behaviors should consume a stable `this.erth.viewport` or `game` API instead of hard-coded window metrics. + +## Affected Files + +- [x] `client/packages/editor-oss/src/EngineRuntime.ts` +- [x] `client/packages/editor-oss/src/behaviors/game/GameManager.ts` +- [x] `client/packages/editor-oss/src/behaviors/stem/*` +- [x] `client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/types/behavior.d.ts` +- [x] `docs/runtime-api.md` +- [x] `../Games-StemScript/Pirate-Ship-Battle-Royal-v1.0/behaviors/*.yaml` + +## Implementation + +- [x] Add a measured safe-area object based on the runtime viewport rect. +- [x] Expose the API through `this.erth.viewport` and `game.getViewportSafeArea()`. +- [x] Update Pirate Ship Battle Royale DOM/UI behaviors to use the safe area for layout and screen projection. + +## Validation + +- [x] Run a narrow test for the safe-area measurement/interface. +- [x] Run targeted diagnostics/type checks for touched files. +- [x] Manual code review. \ No newline at end of file diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 5b5f7b36..c0a8b8db 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -11,6 +11,7 @@ this.erth.ai this.erth.asset this.erth.camera this.erth.object +this.erth.viewport this.erth.scene this.erth.store this.erth.behaviors @@ -105,6 +106,19 @@ this.update = function () { }; ``` +`erth.viewport` exposes the current visible runtime rect after Stem Studio host chrome is accounted for. It is the right source for DOM overlays, screen-space markers, or custom UI camera alignment that must avoid the player nav or registered host overlays such as the player watermark. + +```ts +const safe = this.erth.viewport.getSafeArea(); +// { left, top, right, bottom, width, height, insetLeft, insetTop, insetRight, insetBottom } + +this.onStart = function () { + const safe = this.erth.viewport.getSafeArea(); + this.hud.style.left = `${safe.left + safe.width / 2}px`; + this.hud.style.bottom = `${safe.insetBottom + 16}px`; +}; +``` + For lower-level object and physics details, see [GameObject and GameManager API](/docs/gameobject-and-game-manager-api). ## Store