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: 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);