The Stage

The window your cart gets, and how to fit a fixed board inside it

Your cart gets the whole window. useStage() tells you how big it is, in logical pixels, and re-renders when that changes, so a layout built from flexbox and percentages reflows to fit.

import { useStage } from "oj"

const { width, height } = useStage()

Pointer events, input.mouse.position and touch positions are all in those same window pixels, so a layout and a hit test agree without any conversion.

There is nothing to declare. A stage left in an older oj.json is not read.

A world takes its size from the stage

Anything with a simulation in it (physics, a flock, a particle field) runs against bounds, and those bounds should be the stage rather than a number you wrote down. usePhysics builds its walls around the host element you hand it, so sizing that element from useStage() is the whole of it:

const stage = useStage()
const box = useRef(null)
const world = usePhysics(box, { gravity: [0, 980], bounds: true, bodies })

return <View ref={box} style={{ width: stage.width, height: stage.height }}>...</View>

Place things as fractions of stage.width and stage.height, not in units of the size you happened to design at.

The world is built once, on mount, and is not rebuilt when the window changes, since rebuilding drops every body's velocity. To follow a resize, remount by keying on the size:

<World key={`${stage.width}x${stage.height}`} />

A fixed board

Some carts are not apps. A board, a puzzle, an arcade playfield: you lay it out once at a size that suits it and every coordinate means the same thing forever. Lay it out at that size and scale it to fit the window:

import { useStage, View } from "oj"

const W = 960, H = 540   // the board, in board units

// A fixed W x H board, scaled to fit the window and centred.
function useBoard() {
    const { width, height } = useStage()
    const scale = Math.min(width / W, height / H)
    const left = (width - W * scale) / 2, top = (height - H * scale) / 2
    return {
        scale,
        style: { position: "absolute", left: (width - W) / 2, top: (height - H) / 2, width: W, height: H, scale } as const,
        toBoard: (p: { x: number; y: number }) => ({ x: (p.x - left) / scale, y: (p.y - top) / scale }),
    }
}

function Game() {
    const board = useBoard()
    return <View style={board.style}>{/* everything laid out in 960 by 540 units */}</View>
}

left and top use the unscaled size because a scale is applied around the element's centre.

Positions then need one step to reach board units:

  • A handler on an element inside the board needs nothing. UI Toolkit picks through the scale.
  • e.localX and e.localY are window pixels relative to the element. Divide by board.scale.
  • e.x and e.y, input.mouse.position and touch positions are window pixels. Pass them through board.toBoard(p).
  • Deltas, such as a drag's movement, divide by board.scale. useSwipe thresholds stay in window pixels.

The space around the board shows whatever is behind it. For a colour there, put a full-window View with a backgroundColor behind the board.

Fullscreen

Fullscreen gives your cart a bigger window and nothing else changes: useStage() reports the new size and the layout follows. The page around the cart owns the fullscreen request, because the browser requires a user gesture and the permission lives on that side of the frame boundary.