Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

touchControls

On-screen touch overlay: a renderer + worker, not an event-driven widget net. The worker reads raw touches (Zin.touch.slots()) and routes them into zone geometry derived from the live bindings, writing Zin.virtual (stick/button/drag). Zin.tick runs the worker early — after Virtual._beginFrame() clears the previous tick's drag deltas and before the binding evaluators read Zin.virtual — so the touch binding kinds consume those values on the SAME tick they're written. The renderer (a canvas screen declaring pointerEvents = "none") only draws and takes no pointer, so every widget beneath it, in any screen at any layer, still receives the presses that land on it. Backs Zin.touchControls.

Who a contact belongs to

  • A contact that begins on a drawn button, the fan or an open sheet entry is that control's alone. The overlay states those circles to the input layer with input.setTouchClaims (in the window's pixels, where the input layer meets a contact as it arrives), and a contact beginning inside one drives no pointer: no widget beneath the button is pressed by it, and no mouse button reads held.
  • A contact that begins anywhere else drives the pointer as the primary contact always does, and the UI decides whether a widget takes its press. When one does, the contact is the widget's: on the tick after it began, the stick or look zone that took it lets it go before it has moved anything.
  • Everything left falls to the stick and look zones below.

Coordinate space

Touch contacts (Zin.touch.slots() — t.x/t.y/t.dx/t.dy) carry PHYSICAL pixels of the scene viewport the overlay draws over, measured from its top-left: the space Zin.state.get().mouse_position reads the pointer in. Every widget geometry computation — button/stick positions, the canvas root's style.width/height — lives in ui.screenSize()'s LOGICAL space (the space ui.registerScreen / ui.getLayoutInfo use), which differs from the physical space whenever the display's pixels_per_point ~= 1 (HiDPI). An internal logicalScale helper computes the screenSize / viewportSize ratio once per tick, and every touch coordinate this module reads is scaled by it before use.

Safe area

Every control keeps inside the part of the overlay no system bar, display cutout or on-screen keyboard covers. ui.safeArea() states that part of the picture the overlay's screen lays out in (the scene viewport's, at ui.screenSize()), and the overlay takes it into its own space, which leaves a safe rect L/T in from the left and top edges, SW x SH in size (the whole picture where the picture lies inside the window's safe area, as a Scene panel does). The zones, the button column and the stick all lie inside it, a contact beginning outside it starts none of them, and layout().safe reports it.

Size on the glass

Every radius, inset and gap is in the points ui.screenSize() reports. On a phone or a tablet a point covers at least the display's density (ui.displayScale()), so the controls keep one physical size on every phone and in both orientations: a 40 point "small" button is 80 dp across, past the 48 dp touch target. layout() reports the radii in points; multiply by the pixels one of the overlay's points covers for their size on the glass: the width ui.getLayoutInfo("zin_touch_controls_canvas") gives its canvas, over layout().safe.width, times ui.pixelRatio(). Button labels are canvas text sized from the button's radius, so the user's text scale leaves them inside their circles.

Zone geometry (fractions of the safe rect, SW/SH)

  • stick - activation region x < L + 0.4*SW and y > T + 0.4*SH (left 40%, bottom 60%). A floating stick: the base spawns at the touch's start point, the thumb clamps to a 96px radius, and the vector fed to Zin.virtual.setStick is offset / 96 with y screen-down positive (matching setStick's documented convention).
  • buttons — one circle per touchButton binding among the LIVE bindings (Zin.scheme.bindings() — what awake components asked for, so every button corresponds to a control some world actually consumes), budgeted into a two-column band along the safe rect's right edge, between 45% of its height and its bottom edge less the inset. Buttons sort by (priority, id) - the touchButton's priority option, authored default 50, synthesized default 100 — with same-group buttons kept adjacent, then fill the two columns growing inward from the bottom-right corner. Each circle is the radius ITS OWN size option names (small 40px, medium 56px, large 72px), so a scheme mixing a large action button with a small pause pip draws them at 72 and 40 whatever else is live; the largest of those radii is the slot pitch and the row budget the column packs on, so the set decides where a button sits and the button's own declaration decides how big it is. More than 4 authored buttons walk every button one step down its own size, which layout() reports as appliedSize. Anything past the two-column budget doesn't render individually — the last slot becomes a ... fan button; tapping it opens a rows-of-3 grid sheet holding the overflow (same tap/hold behavior as a regular button), and tapping the fan again, or any layout-invalidating context/map change, closes it.
  • drag - everything else in the safe rect at x >= L + 0.4*SW (accumulates into Zin.virtual.addDrag every tick from the contact's per-frame delta).
  • the remaining top-left quadrant (x < L + 0.4*SW and y <= T + 0.4*SH) is unclaimed - free for other UI.

A control is drawn only when it is live AND subscribed: a button offered for a control nothing listens to is an offer that cannot be kept, so the layout tracks Zin.scheme.subscriberEpoch() alongside Zin.scheme.generation().

Only bindings whose context matches the current input context (Zin.context.current()) contribute controls, so a vehicle-context Brake button neither renders nor claims touches while the default context is on top. The layout rebuilds when the live set, the subscriber count, the context, the screen size or the safe area changes.

A record-shaped map applied through Zin.map.activate contributes its touch classes too. A control whose name is already live as an asset is skipped, so a world carrying both never draws the same control twice under two ids.

A contact is assigned to a region on its began phase only (a contact-id -> assignment map) and released on ended / cancelled / vanishing from Zin.touch.slots(). Button circle hit-tests (visible buttons, the fan, the open sheet's entries) take precedence over the stick/drag zone checks — a button is a precise target and wins wherever it lands, including inside a zone (the open sheet grows leftward and can reach the stick zone on a narrow screen). A contact claimed by the stick or a button never also feeds the drag zone (a contact is assigned to exactly one region), so its projected-mouse / touchDrag state never competes with a look/aim consumer reading Zin.virtual.drag — no extra suppression is needed beyond the region assignment itself.

A pinch and the drag region share the same space, so the two are arbitrated once per tick. The contacts the stick and the buttons hold are published to Zin.gestures, which forms its two-finger pair out of what is left; while the active map declares a touchPinch control and that pair is down, the drag region reports nothing. A pinch made over the look surface therefore reaches the pinch control alone, a thumb resting on the movement stick is no part of a pinch so the other thumb keeps looking, and lifting one finger from a pinch drags again.

Rendering

One screen ("zin_touch_controls", layer 200) whose root is a single transparent canvas sized to the safe rect, taking no pointer. The screen lays out inside the safe area, so the canvas starts at the rect's corner and every command is drawn from there. Commands rebuild only when the draw state actually changed (a dirty flag) — buttons are always drawn while mounted; the stick draws only while a stick contact is live.

Auto-mount

Every _advance() checks Zin.surface.current() — "touch" registers (lazily, once) and shows the screen; anything else hides it. Visibility is purely the surface class, in edit and play alike. M._forceMount(true) bypasses the surface gate for headless testing. Touch routing itself is unconditional: it runs every tick regardless of mount state, so a forced or real touch always drives Zin.virtual even before the overlay is visible.

Adding a control while the world runs

The overlay draws a button for a control that is live and subscribed, so adding one at runtime is adding a control asset, activating it, and subscribing to it — the three steps a component takes in awake, in one call:

local h = Zin.touchControls.addControl({
    name = "cast", label = "Cast",
    kbm = Zin.bindings.key("KeyF"),
    gamepad = Zin.bindings.padButton("north"),
    onPressed = function() castSpell() end,
})
Zin.touchControls.removeControl(h)

The control becomes a real <name>.inputMap/<name>.inputBinding/ under /source/runtimeControls/, so it is inspectable, rebindable and publishable like any other, and its name is its id in Zin.scheme.bindings() / Zin.scheme.fired(). removeControl releases it — off the screen, out of the live set, every subscription dropped — and leaves the asset, so re-adding the same name reuses it and its guid.

M.button(opts) is the same idea against a record-shaped map applied through Zin.map.activate, in two flavors:

  • { action = "jump" } attaches an extra touchButton binding to an existing action on the active map.
  • { emit = "KeyF", label = "Cast" } registers an overlay-only button: a synthetic emit:KeyF action carrying both the kbm key binding and the touchButton, so the emulation floor drives KeyF through the same binding/emulation path every other control uses.

Both return a handle for M.removeButton(handle).

Exports

  • M.addControl(opts: ControlOpts) -> number / M.removeControl(handle) -> boolean — add or release a control while the world runs.
  • M.controls() -> { { handle, name, path } } — every control addControl currently holds, in the order they were added.
  • M.button(opts) -> number / M.removeButton(handle) -> boolean — the button API described above.
  • M.claimed() -> { number } — the contact ids currently claimed by the stick, a button, or the drag zone.
  • M.primaryClaimed() -> boolean — whether an on-screen control owns the primary contact. Zin.bindings consults this so a thumb on the stick or the look area does not also fire whatever the world bound to left-click.
  • M.layout() -> { buttons, fan?, stick?, drag?, pinch?, width, height, safe } - the current control layout (a copy): buttons is the budgeted, VISIBLE stack in draw order, each entry { id, label, actionName, priority, size, appliedSize, group, cx, cy, radius } in ui.screenSize() space — size is the step the binding declared and appliedSize the one radius came from, which differ only where a rich scheme's step-down moved a button one size down; fan is nil unless the button set exceeded the two-column budget, else { open, cx, cy, radius, buttons } — open is whether the overflow sheet is expanded, buttons the overflowed entries at their sheet positions (tappable only while open); stick / drag name the axis + virtual zone each region feeds, and pinch the axis the two-finger recognizer feeds; safe is the safe rect { x, y, width, height } every control keeps inside. Backs custom control-placement UI and headless tests that aim simulated touches at a button's (or the fan's) center.
  • M._advance() — internal: one tick (touch routing, layout, auto-mount, repaint). Wired into Zin.tick, before Zin.emulation._advance().
  • M._forceMount(on: boolean) — test-only: force mounting regardless of Zin.surface.current().
  • M._setViewportOverride(w?, h?) — test-only: override the logical screen size the layout budget/fan math reads, without resizing the real window. Pass nil/nil to clear.
  • M._reset() — test-only: unmount, clear routing/layout state, drop buttons, release every runtime control, close the fan, and clear the viewport override.