BLIT386BLIT386

Example: Pointer Basics

Read mouse position, movement delta, scroll wheel, and four pointer buttons, with a live crosshair.

Written for blit386 1.8.0. The rest of blit386.dev documents the newest engine, so an API you find there may be missing from a game pinned to 1.8.0.

Live version: https://demos.blit386.dev/pointer-basics. This is the demo-site source, not a standalone file: it imports and calls helpers from ./shared/ (themed panels, on-screen D-pad, post-process fallbacks), which are not part of blit386 and are not on npm. To run it as is, keep those files from https://github.com/blit386/blit386/tree/main/packages/demos/src/shared at the same relative path. To adapt it, remove or replace the helper imports and every call to them.

pointer-basics.js
/*

Pointer Basics: read mouse position, buttons, movement delta, and the scroll wheel.
@description Read mouse position, movement delta, scroll wheel, and four pointer buttons, with a live crosshair.

Part of the BLIT386 demo series.

Prerequisites:
  - Basics: https://demos.blit386.dev/basics
  - Primitives: https://demos.blit386.dev/primitives

Live version: https://demos.blit386.dev/pointer-basics

Guides:
  - Input: https://blit386.dev/docs/guides/input#position-and-delta

Reference:
  - Core: https://blit386.dev/docs/api/core#hardware-settings

---

WHAT YOU WILL SEE
-----------------

A dark screen with a title strip across the top and two equal panels just below it. On
the left, live readouts for pointer slot 0 - whether it is active, its position, how far
it moved this frame, how far the wheel turned, and a meter that fills as you scroll
down. On the right, four pips labeled A to D that light up while the matching mouse
button is held, and a count of the touch slots (1-3) in use.

A white crosshair follows your mouse (or your finger), trailing a cyan line through its
last 24 positions. The crosshair stays on top of the panels; the trail passes behind
them. While no pointer is over the canvas, a dim "Move pointer over canvas" hint sits in
the middle of the open space under the panels instead.

WHAT YOU WILL LEARN
-------------------

- The pointer slot model: slot 0 is always the mouse, slots 1-3 are fingers (or a pen) in
  the order they touched down.
- BT.isPointerActive(slot) says whether a slot has a live pointer right now.
- BT.pointerPos(slot) and BT.pointerDelta(slot) return the position and the movement
  since the last update, both in display pixels.
- BT.isDown(BT.BTN_POINTER_A, 0) and friends read the four mouse buttons: A is left, B is
  right, C is middle, D is the back or forward button. Touch slots only ever report A.
- BT.pointerScrollDelta is the wheel movement for the current frame. It stays 0 unless
  configure() sets isCapturingPointerScroll: true.
- BT.hideCursor() hides the OS cursor so a drawn crosshair can take its place.
- Pointer state is held state, not an edge, so reading it from render() is safe.

TRY THIS
--------

- Move the mouse over the demo: the crosshair and its trail follow slot 0.
- Hold the left, right, or middle button to light up the A, B, or C pip. A mouse with a
  back or forward button lights up D.
- Spin the scroll wheel to fill or empty the "Scroll pos" meter.
- On a touchscreen, drag a finger to move the crosshair on slots 1-3 (slot 0 stays
  reserved for the mouse). The wheel and the extra buttons have no touch equivalent, so a
  one-line note appears in the bottom-right corner once a touch is seen. Pointer Paint
  (https://demos.blit386.dev/pointer-paint) uses all four slots at once.
- Press the backquote key (`) to open the engine overlay and watch the timing chart.

*/

import { bootstrap, BT, Color32, Rect2i, Vector2i } from 'blit386';

import { applyTheme, FONT_W, ui, UI_ANCHORS } from './shared/ui.js';

/** @typedef {import('blit386').Palette} Palette */

// Scene palette slots. Slot 0 is always transparent. All the UI colors (panels, text,
// meter fill) come from the shared theme installed by applyTheme() in init(), so the demo
// only needs slots for its own artwork: the crosshair and the cyan trail behind it.
const C_CROSSHAIR = 1; // white crosshair that follows the pointer
const C_TRAIL = 2; // cyan trail line behind the crosshair

// Number of past positions remembered for the cursor trail. Each tick we shift in the
// latest position and draw a line through all of them.
const TRAIL_LENGTH = 24;

// Multiplier applied to the raw scroll delta (BT.pointerScrollDelta) before it is added
// to the scroll position. The browser reports scrolling in CSS pixels, which adds up fast
// - shrinking each report to a quarter keeps one wheel click moving the meter a few
// pixels instead of a big jump. update() then clamps the scroll position to the display
// height so the meter fill always stays between empty and full.
const SCROLL_SENSITIVITY = 0.25;

// Layout: two equal panels, 152 pixels wide and 8 apart, sit 4 pixels below the
// 22-pixel title strip and 4 pixels from the screen sides (4 + 152 + 8 + 152 + 4 = 320).
// Both reserve 63 pixels of rows, so they end on the same line. Everything below them is
// open space for the crosshair.
const PANEL_Y = 26;
const READOUTS_W = 152;
const BUTTONS_W = 152;
const PANELS_BOTTOM = PANEL_Y + 22 + 63 + 11;

// Half the length of each crosshair arm, in pixels.
const CROSSHAIR_ARM = 6;

// Demonstrates the basic pointer API: position, delta, scroll wheel, and the four pointer
// buttons (A, B, C, D) on slot 0 (the mouse).
class Demo {
    /** @type {Palette | null} */
    palette = null;

    // Palette slot map returned by applyTheme() - theme.bg, theme.dim, and so on. Filled
    // in init(); used for the screen clear.
    theme = null;

    // Recent positions, oldest first. Each tick we push the current position and drop the
    // oldest, so the trail shows the cursor's recent path. Each entry is [x, y].
    trail = [];

    // Reused for the two crosshair arms and the two ends of each trail segment, so
    // drawing allocates no new objects.
    armRect = new Rect2i(0, 0, 0, 0);
    lineFrom = new Vector2i(0, 0);
    lineTo = new Vector2i(0, 0);

    // Accumulated scroll position in display pixels. Centered in init() from
    // BT.displaySize. BT.pointerScrollDelta pushes it up or down, and the scroll meter in
    // the readouts panel shows it as a 0..1 fill.
    scrollPos = 0;

    // Turns on wheel capture and the overlay timing chart.
    configure() {
        return {
            // Opt into canvas wheel capture so BT.pointerScrollDelta works and the page
            // does not scroll while the pointer is over the demo.
            isCapturingPointerScroll: true,
            isOverlayEnabled: false,
        };
    }

    // Runs once at startup. Sets up the palette and prefills the trail.
    async init() {
        this.palette = BT.paletteCreate(256);

        // Scene colors: just the crosshair and its trail. Everything else on screen
        // (panels, labels, the meter) is drawn by the shared UI kit.
        this.palette.set(C_CROSSHAIR, new Color32(255, 255, 255));
        this.palette.set(C_TRAIL, new Color32(80, 200, 255));

        // Install the shared UI colors (slots 240-251) and keep the slot map so we can
        // clear with the theme background and reuse the dim text color for the hint.
        this.theme = applyTheme(this.palette);

        BT.paletteSet(this.palette);

        const screen = BT.displaySize;
        this.scrollPos = Math.floor(screen.y / 2);

        // Hide the native OS cursor so the drawn crosshair is the only cursor visible
        // while the pointer is over the canvas.
        BT.hideCursor();

        // Prefill the trail with the center point so the very first frame has something
        // to draw without a special-case "no history yet" path.
        for (let i = 0; i < TRAIL_LENGTH; i++) {
            this.trail.push([Math.floor(screen.x / 2), Math.floor(screen.y / 2)]);
        }

        return true;
    }

    // Per-tick update: push the current pointer position into the trail and apply the
    // scroll-wheel delta to the accumulated scroll position.
    update() {
        // Let the UI kit do its per-tick housekeeping first. This demo asks the kit
        // whether a touchscreen has been used (ui.hasTouch() in render()), and that
        // answer is kept fresh here.
        ui.tick();

        // Only record the trail when a pointer is over the canvas. The mouse uses slot 0;
        // touches use slots 1-3. If none are active, keep the previous trail intact so
        // the line does not snap to (0, 0).
        const slot = this.activePointerSlot();

        if (slot >= 0) {
            const pos = BT.pointerPos(slot);

            // Drop the oldest sample and append the new one.
            this.trail.shift();
            this.trail.push([pos.x, pos.y]);
        }

        // Convert the scroll delta (CSS pixels of scrolling) into a small movement.
        // Multiplying by a fraction makes one wheel click move the meter a few pixels
        // instead of jumping a full screen height.
        this.scrollPos += BT.pointerScrollDelta * SCROLL_SENSITIVITY;

        // Keep the scroll position inside the visible range, using the display height
        // rather than a hard-coded 240.
        const maxScrollY = BT.displaySize.y;

        if (this.scrollPos < 0) {
            this.scrollPos = 0;
        } else if (this.scrollPos > maxScrollY) {
            this.scrollPos = maxScrollY;
        }
    }

    // Per-frame render: clear, draw the UI kit panels (readouts and button pips), then
    // the cursor trail and the crosshair. The trail is plain lines, which the engine
    // draws before every sprite, so it passes behind the panels; the crosshair is drawn
    // as kit sprites after the panels, so the "cursor" is never hidden behind one.
    render() {
        // Clear to the shared theme background so this demo matches the rest of the
        // series.
        BT.clear(this.theme.bg);

        // Full-width title strip with the one-line instructions.
        ui.begin(UI_ANCHORS.TOP_BAR);
        ui.panel('Pointer Basics - move, click, spin the wheel');
        ui.end();

        this.renderReadouts();
        this.renderButtonPips();
        this.renderTouchNote();
        this.renderTrail();
        this.renderCrosshair();
        this.renderPointerHint();
    }

    // Readouts panel in the top-left: pointer position, delta, wheel delta, whether slot
    // 0 is active, and a meter showing the accumulated scroll position. The raw
    // BT.pointer* reads here are the whole point of the demo - pointer state (unlike
    // keyboard edges) is safe to read from render().
    renderReadouts() {
        // Is the mouse currently over the canvas?
        const active = BT.isPointerActive(0);

        // How far the wheel moved this frame (positive = scrolling down).
        const scroll = BT.pointerScrollDelta;

        // The panel starts just below the title strip, at a fixed width so its edge does
        // not jump while the numbers in it change length.
        ui.begin(UI_ANCHORS.TOP_LEFT, { y: PANEL_Y, width: READOUTS_W });
        ui.panel('Slot 0 (mouse)');

        ui.kv('Active', active ? 'yes' : 'no');

        // Only read position and delta when the pointer is over the canvas. The engine
        // keeps the last known values after the mouse leaves, so they would be stale.
        if (active) {
            const pos = BT.pointerPos(0);
            const delta = BT.pointerDelta(0);

            ui.kv('Pos', `${pos.x},${pos.y}`);
            ui.kv('Delta', `${delta.x},${delta.y}`);
        } else {
            ui.kv('Pos', '--,--');
            ui.kv('Delta', '--,--');
        }

        // toFixed(1) turns the number into text with one digit after the decimal point,
        // so the readout does not jitter through long fractions.
        ui.kv('Wheel', scroll.toFixed(1));

        // The meter fills up as you scroll down and empties as you scroll up. Dividing by
        // the display height turns 0..240 pixels into the 0..1 fraction the meter
        // expects.
        ui.meter('Scroll pos', this.scrollPos / BT.displaySize.y, { width: READOUTS_W - 20 });

        ui.end();
    }

    // Buttons panel in the top-right: four read-only pips, one per pointer button, and
    // a count of the touch slots in use. A pip lights up while its button is held and
    // goes hollow when released. BT.isDown() checks held state, which is safe from
    // render().
    renderButtonPips() {
        ui.begin(UI_ANCHORS.TOP_RIGHT, { y: PANEL_Y, width: BUTTONS_W });
        ui.panel('Buttons');

        // Each pip pairs a label with the live held state of one button code.
        ui.pip('A (left)', BT.isDown(BT.BTN_POINTER_A, 0));
        ui.pip('B (right)', BT.isDown(BT.BTN_POINTER_B, 0));
        ui.pip('C (middle)', BT.isDown(BT.BTN_POINTER_C, 0));
        ui.pip('D (extra)', BT.isDown(BT.BTN_POINTER_D, 0));

        // How many fingers are down right now: slots 1-3 are the touch slots. The
        // 13-pixel gap above it makes this panel's rows add up to the same 63 pixels as
        // the readouts panel beside it (4 pips x 10 + 13 + 10).
        let touches = 0;

        for (let slot = 1; slot < 4; slot++) {
            if (BT.isPointerActive(slot)) {
                touches++;
            }
        }

        ui.spacer(13);
        ui.kv('Touches', touches);

        ui.end();
    }

    // A dim one-line note shown only after a touchscreen has been used. Fingers can move
    // the crosshair, but there is no touch equivalent of the scroll wheel or the extra
    // mouse buttons - better to say so than to let touch users hunt for something that
    // cannot happen.
    renderTouchNote() {
        if (!ui.hasTouch()) {
            return;
        }

        // A borderless group (no ui.panel call) is just floating text.
        ui.begin(UI_ANCHORS.BOTTOM_RIGHT);
        ui.label('Scroll wheel and right-click: desktop only', { color: 'dim' });
        ui.end();
    }

    // A line through the recent pointer positions, all drawn in C_TRAIL.
    renderTrail() {
        if (this.activePointerSlot() < 0) {
            return;
        }

        for (let i = 1; i < this.trail.length; i++) {
            const [ax, ay] = this.trail[i - 1];
            const [bx, by] = this.trail[i];
            this.lineFrom.set(ax, ay);
            this.lineTo.set(bx, by);
            BT.drawLine(this.lineFrom, this.lineTo, C_TRAIL);
        }
    }

    // Crosshair drawn at the current pointer position. Only shown while a mouse (slot 0)
    // or touch (slots 1-3) pointer is over the canvas.
    renderCrosshair() {
        const slot = this.activePointerSlot();

        if (slot < 0) {
            return;
        }

        const pos = BT.pointerPos(slot);

        // Two 1-pixel-thick arms that cross at the pointer, each 13 pixels long (6 on
        // either side plus the center pixel). They are drawn with ui.drawRectFill(), the
        // sprite version of BT.drawRectFill(): the engine draws every BT.drawLine() and
        // BT.drawRectFill() before any sprite, and the kit panels are sprites, so a plain
        // line would vanish under a panel whenever the pointer moved over one.
        const span = CROSSHAIR_ARM * 2 + 1;

        this.armRect.set(pos.x - CROSSHAIR_ARM, pos.y, span, 1);
        ui.drawRectFill(this.armRect, C_CROSSHAIR);
        this.armRect.set(pos.x, pos.y - CROSSHAIR_ARM, 1, span);
        ui.drawRectFill(this.armRect, C_CROSSHAIR);
    }

    // "Move pointer over canvas" hint, centered in the open space under the panels.
    // Only shown while no mouse or touch pointer is over the canvas, so newcomers know
    // what to do. Uses the theme's dim text color so it matches the rest of the UI.
    renderPointerHint() {
        if (this.activePointerSlot() >= 0) {
            return;
        }

        // The open space runs from the bottom of the panels to the bottom of the screen.
        // Every character of the system font is FONT_W (6) pixels wide, so starting the
        // text half its width left of the middle centers it; a caption's capital letters
        // cover 7 rows starting 1 row above the y it is given, so centerY - 2 centers
        // them vertically.
        const text = 'Move pointer over canvas';
        const screen = BT.displaySize;
        const centerX = Math.floor(screen.x / 2);
        const centerY = Math.floor((PANELS_BOTTOM + screen.y) / 2);

        ui.caption(centerX - (text.length * FONT_W) / 2, centerY - 2, text, { color: 'dim' });
    }

    // First active pointer slot this frame: mouse (0) preferred, then touch (1-3).
    // Returns -1 when no pointer is over the canvas.
    activePointerSlot() {
        if (BT.isPointerActive(0)) {
            return 0;
        }

        for (let slot = 1; slot < 4; slot++) {
            if (BT.isPointerActive(slot)) {
                return slot;
            }
        }

        return -1;
    }
}

bootstrap(Demo);