Example: Gamepad Input
A tiny hover-pod playground for gamepads: connect status, analog sticks, triggers, and button masks.
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/gamepad-input. 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.
/*
Gamepad Input: analog sticks, triggers, and face-button masks.
@description A tiny hover-pod playground for gamepads: connect status, analog sticks, triggers, and button masks.
@ogScale integer
Part of the BLIT386 demo series.
Prerequisites:
- Basics: https://demos.blit386.dev/basics
- Keyboard Input: https://demos.blit386.dev/keyboard-input
Live version: https://demos.blit386.dev/gamepad-input
Guides:
- Input: https://blit386.dev/docs/guides/input#keyboard
- Input: https://blit386.dev/docs/guides/input#frame-timing-semantics
- Game Loop: https://blit386.dev/docs/guides/game-loop#smoothing-motion-between-updates
- Overlay: https://blit386.dev/docs/guides/overlay#optional-bands
Reference:
- Game Loop: https://blit386.dev/docs/api/game-loop#interpolating-render-state-with-renderalpha
- Overlay: https://blit386.dev/docs/api/overlay#event-tags
---
WHAT YOU WILL SEE
-----------------
A title strip across the top and two columns under it, both ending on the same line.
On the left, a "Controls" panel lists what each stick, trigger, and button does. Below it,
a dark arena with a border fills the rest of the column: inside it a small square "hover
pod" with a white outline and a small crosshair. Until a gamepad is connected, a warm
prompt sits near the top of the arena (on a touch screen it says "This demo needs a
gamepad" instead).
On the right, a status panel from the shared UI kit, titled with how many gamepads the
browser reports ("Gamepads: 0"): a pip for player one's connection, pips for A, B, the
(A|B) mask, any button, L2 and R2, then the raw left and right stick numbers (LX, LY, RX,
RY) and a "Throttle" meter that fills with trigger pressure. Under it, a "Pod" panel
shows the pod's current color, whether the trail is on, and a "Boost" meter that empties
as an R2 boost runs out. When the gamepad connects, a "Gamepad connected" tag lands on the
engine overlay's timing chart.
WHAT YOU WILL LEARN
-------------------
- BT.isGamepadConnected(player) and BT.gamepadCount for connection status, with a simple
rising-edge check to react once when a pad appears.
- BT.getAxis(axis, player) with BT.AXIS_LEFT_X / AXIS_LEFT_Y / AXIS_RIGHT_X / AXIS_RIGHT_Y
(-1 to +1) and BT.AXIS_TRIGGER_L / AXIS_TRIGGER_R (0 to 1), dead-zone filtered by the
engine.
- Button masks: BT.isDown(BT.BTN_A | BT.BTN_B, player) is true when either button is
held.
- The triggers as digital buttons: BT.isDown(BT.BTN_L2, player) and
BT.isPressed(BT.BTN_R2, player) turn on at half pull.
- BT.isAnyButtonDown(player) for "is anything held" checks.
- Smoothing motion with BT.renderAlpha and Vector2i.lerp between the previous and current
tick positions.
- BT.PLAYER_ONE as the player index, and BT.assignTag for the timing chart.
HOW THE POD IS STEERED
----------------------
- The left stick moves the pod around the arena; the right stick moves the crosshair.
- Trigger pressure grows the pod from 10 up to 20 pixels (a little "throttle" feeling).
Whichever trigger reads higher wins.
- A cycles the pod through three colors, B toggles a breadcrumb trail, Start recenters
the pod and crosshair.
- R2, read as a button, gives the pod a 20-tick speed boost at double speed.
Face buttons for player 0 merge keyboard and gamepad, so the default keyboard map also
works for A (Space or B), B (N), and Start (5). The sticks, triggers, L2, and R2 are
gamepad only.
TRY THIS
--------
- Plug in a gamepad and press any button so the browser wakes it up.
- Move the left stick to fly the pod; move the right stick to drag the crosshair. The
default dead zone is large, so push the stick most of the way before anything moves.
- Squeeze either trigger and watch the pod grow and the Throttle meter fill.
- Hold A and B together and watch the "(A|B) mask" pip in the panel light up.
- Pull R2 past half way while moving the left stick to feel the boost; the "R2 held" pip
lights once R2 passes half pull.
- Press B to turn the trail on, fly around, and press B again to clear it.
- Press the backquote key (`) to open the engine overlay and look for the "Gamepad
connected" tag on the timing chart.
*/
import { bootstrap, BT, Color32, Rect2i, Vector2i } from 'blit386';
import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';
/** @typedef {import('blit386').Palette} Palette */
// Screen size in display pixels. 288 is tall enough for every row of the status panel.
const DISPLAY_W = 320;
const DISPLAY_H = 288;
// Every gamepad read in this demo is for the first player slot (index 0).
const PLAYER = BT.PLAYER_ONE;
// Scene palette indices. Slot 0 stays transparent; toy colors start at 1. The shared UI
// kit's applyTheme() adds its own twelve colors way up at slots 240+, so nothing
// collides.
const C_POD_OUTLINE = 1; // pod outline white; doubles as the timing-chart render bar
const C_ARENA = 2; // filled interior of the steering arena
const C_ARENA_BORDER = 3; // arena border rectangle
const C_CHART_DIM = 4; // timing-chart update bar (soft gray)
const C_CHART_TAG = 5; // timing-chart milestone tag color (amber)
const C_POD_A = 6; // first pod color (cycles with A)
const C_POD_B = 7; // second pod color
const C_POD_C = 8; // third pod color
const C_TRAIL = 9; // white with a lower alpha value, for the trail pixels
const C_AIM = 10; // right-stick crosshair color
// Layout on a 4-pixel grid: 4 pixels from the screen edges, 4 between panels. Two
// columns under the 22-pixel title strip, 204 and 104 pixels wide
// (4 + 204 + 4 + 104 + 4 = 320).
//
// - Right column: the Gamepad panel (22 title + 7 pip rows + 4 axis rows + a 23-pixel
// meter + 11 bottom = 166 pixels), then the Pod panel (22 + 2 rows + a meter + 11 =
// 76 pixels). Both columns end at y = 272, above the bottom-left 17x13 pixels the
// engine overlay keeps for its toggle hint.
// - Left column: the Controls panel (22 + 4 rows + 11 = 73 pixels), then the arena,
// which stretches down to the same bottom line as the right column.
const EDGE = 4;
const GAP = 4;
const TOP_Y = 26;
const LEFT_W = 204;
const RIGHT_W = 104;
const RIGHT_X = DISPLAY_W - EDGE - RIGHT_W;
const GAMEPAD_PANEL_H = 22 + 7 * 10 + 4 * 10 + 23 + 11;
const POD_PANEL_Y = TOP_Y + GAMEPAD_PANEL_H + GAP;
const POD_PANEL_H = 22 + 2 * 10 + 23 + 11;
const COLUMN_BOTTOM = POD_PANEL_Y + POD_PANEL_H;
const CONTROLS_PANEL_H = 22 + 4 * 10 + 11;
// Meters in the right column fill the panel's content width (the panel minus 9 pixels of
// padding on each side).
const METER_W = RIGHT_W - 18;
// The play arena rectangle, in display pixels: under the Controls panel, down to the
// bottom of the right column.
const ARENA_X = EDGE;
const ARENA_Y = TOP_Y + CONTROLS_PANEL_H + GAP;
const ARENA_W = LEFT_W;
const ARENA_H = COLUMN_BOTTOM - ARENA_Y;
// Names of the three pod colors, in the same order A cycles through them.
const POD_COLOR_NAMES = ['green', 'red', 'blue'];
// Pod size in pixels: 10 at rest, up to 10 more at full trigger pull. POD_SPEED is how
// many pixels per tick a fully pushed stick moves it.
const POD_BASE_SIZE = 10;
const POD_MAX_EXTRA_SIZE = 10;
const POD_SPEED = 3;
// An R2 press gives the pod a short speed burst: BOOST_TICKS update ticks at BOOST_FACTOR
// times the normal speed.
const BOOST_TICKS = 20;
const BOOST_FACTOR = 2;
// Pixels per tick for the right-stick crosshair, and how many trail points we keep.
const AIM_SPEED = 4;
const TRAIL_MAX = 28;
// Formats a stick axis value (-1 to +1) as a fixed-width string like '+0.50' or '-0.32'.
// Writing the '+' out keeps every row the same width, so the numbers in the panel do not
// jiggle left and right as the sign flips.
function formatAxis(value) {
// toFixed(2) rounds to two decimal places and always prints both (0.5 -> '0.50').
return (value >= 0 ? '+' : '') + value.toFixed(2);
}
// Tiny gamepad playground that visualizes sticks, triggers, and buttons.
class Demo {
/** @type {Palette | null} */
palette = null;
// Palette slots of the shared UI theme colors, filled by applyTheme() in init().
/** @type {ReturnType<typeof applyTheme> | null} */
theme = null;
// Pod position in integer display pixels.
podPos = new Vector2i(Math.floor(DISPLAY_W / 2), Math.floor(DISPLAY_H / 2));
// Small cursor moved by the right stick so both sticks have visible jobs.
aimPos = new Vector2i(Math.floor(DISPLAY_W / 2) + 28, Math.floor(DISPLAY_H / 2));
// Pod and aim position at the START of the most recent update() tick, before this
// tick's stick input moved them. render() blends between these and podPos/aimPos
// using BT.renderAlpha so the pod and aim cursor glide smoothly between ticks instead
// of jumping - see the Game Loop guide linked in the header. Start equal to
// podPos/aimPos so the very first frame does not blend in from a stale position.
prevPodPos = this.podPos;
prevAimPos = this.aimPos;
// Trail points for the optional motion history, newest last.
/** @type {Vector2i[]} */
trail = [];
// Whether B has switched the trail on.
trailEnabled = false;
// Cycles through three pod colors when A is pressed: 0, 1, 2, then back to 0.
podColorIndex = 0;
// Update ticks of boost left. R2 (the right trigger as a DIGITAL button) starts it.
boostTicks = 0;
// Whether a gamepad was connected at the end of the previous tick, for the
// rising-edge check in update().
wasConnected = false;
// Screen size, timing chart colors, and the keyboard scroll guard.
configure() {
return {
displaySize: new Vector2i(DISPLAY_W, DISPLAY_H),
// The default keyboard layout maps Space to the A button this demo reads -
// keep the page from scrolling when it is pressed.
isCapturingKeyboardScroll: true,
isOverlayTimingChartEnabled: true,
overlayTimingChartStyle: {
updateBarPaletteIndex: C_CHART_DIM,
renderBarPaletteIndex: C_POD_OUTLINE,
tagPaletteIndex: C_CHART_TAG,
},
};
}
// Builds a small custom palette, installs the shared UI theme, and starts centered.
async init() {
// Build a small custom palette before any drawing happens. Think of each slot as
// a numbered paint can the engine looks up while rendering.
this.palette = BT.paletteCreate(256);
this.palette.set(C_POD_OUTLINE, new Color32(245, 248, 255));
this.palette.set(C_ARENA, new Color32(24, 30, 47));
this.palette.set(C_ARENA_BORDER, new Color32(68, 80, 108));
this.palette.set(C_CHART_DIM, new Color32(130, 144, 173));
this.palette.set(C_CHART_TAG, new Color32(255, 190, 95));
this.palette.set(C_POD_A, new Color32(120, 255, 170));
this.palette.set(C_POD_B, new Color32(255, 130, 140));
this.palette.set(C_POD_C, new Color32(120, 185, 255));
this.palette.set(C_TRAIL, new Color32(255, 255, 255, 120));
this.palette.set(C_AIM, new Color32(255, 220, 130));
// The shared UI kit needs its theme colors in the palette before any ui.* call
// can draw. applyTheme() writes them into high slots (240 and up), far away from
// the scene slots above, and hands back a map like { bg: 240, text: 244, ... } so
// this demo can reuse theme colors for its own drawing (we clear with bg).
this.theme = applyTheme(this.palette);
// Tell the engine to use this palette for every draw call from now on.
BT.paletteSet(this.palette);
// Place the pod and aim cursor in the middle of the arena.
this.resetPod();
return true;
}
// Reads gamepad input and updates the toy state.
update() {
// Let the UI kit do its once-per-tick housekeeping first. This demo has no kit
// buttons or key bindings, but ui.tick() also tracks whether the screen has ever
// been touched - that is what feeds the ui.hasTouch() hint drawn in render().
ui.tick();
// Ask the engine whether a gamepad is currently plugged in for this player slot.
const connected = BT.isGamepadConnected(PLAYER);
// "Edge detection": we only want to react the moment the gamepad is first
// connected, not every single tick it stays connected. So we compare the current
// state to what it was last tick (stored in wasConnected). If it just became true
// (false -> true), that is the "rising edge" - the instant of connection.
if (connected && !this.wasConnected) {
// Label this moment on the overlay timing chart so you can see exactly which
// frame the gamepad was detected.
BT.assignTag('Gamepad connected');
}
// Save this tick's connection state so the next tick can compare against it.
this.wasConnected = connected;
// Button edges. For player 0, BT.isPressed merges the keyboard map and the
// gamepad: the keyboard half clears once per update tick, so update() is where
// it must be read. The gamepad half rolls over once per rendered frame instead,
// so on a display faster than targetFPS a gamepad press can land in a frame that
// runs no update and be missed here - see the input guide's frame-timing section.
// A press cycles the pod color once per physical press.
if (BT.isPressed(BT.BTN_A, PLAYER)) {
this.podColorIndex = (this.podColorIndex + 1) % 3;
}
// B press toggles trail drawing.
if (BT.isPressed(BT.BTN_B, PLAYER)) {
this.trailEnabled = !this.trailEnabled;
// Clearing keeps the trail from "teleporting" across toggles.
if (!this.trailEnabled) {
this.trail = [];
}
}
// Start recenters the toy instantly.
if (BT.isPressed(BT.BTN_START, PLAYER)) {
this.resetPod();
}
// R2 is the right trigger read as a button: BT.isPressed fires once per pull past
// half way, like any other button. (BT.getAxis below still gives the analog
// pull.)
if (BT.isPressed(BT.BTN_R2, PLAYER)) {
this.boostTicks = BOOST_TICKS;
}
// Axes are dead-zone filtered by the engine (see renderArena for the details).
const moveX = BT.getAxis(BT.AXIS_LEFT_X, PLAYER);
const moveY = BT.getAxis(BT.AXIS_LEFT_Y, PLAYER);
const aimX = BT.getAxis(BT.AXIS_RIGHT_X, PLAYER);
const aimY = BT.getAxis(BT.AXIS_RIGHT_Y, PLAYER);
const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));
// Remember where the pod and aim cursor were before this tick moves them, so
// render() can draw a smooth in-between position instead of a pop.
this.prevPodPos = this.podPos;
this.prevAimPos = this.aimPos;
// Convert analog float values into integer pixel steps. While boosted, the pod
// moves BOOST_FACTOR times faster; each tick uses one boost tick up.
const speed = this.boostTicks > 0 ? POD_SPEED * BOOST_FACTOR : POD_SPEED;
if (this.boostTicks > 0) {
this.boostTicks -= 1;
}
this.podPos = this.podPos.add(new Vector2i(Math.round(moveX * speed), Math.round(moveY * speed)));
this.aimPos = this.aimPos.add(new Vector2i(Math.round(aimX * AIM_SPEED), Math.round(aimY * AIM_SPEED)));
// Keep both inside the arena. The pod's half size depends on the trigger pull,
// the crosshair is always 4 pixels from center to tip.
const podHalf = this.currentPodHalfSize(throttle);
this.podPos = this.clampToArena(this.podPos, podHalf);
this.aimPos = this.clampToArena(this.aimPos, 4);
// With the trail on, remember this tick's position and drop the oldest point once
// the list is full.
if (this.trailEnabled) {
this.trail.push(this.podPos);
if (this.trail.length > TRAIL_MAX) {
this.trail.shift();
}
}
}
// Draws the toy arena and the UI kit groups: title bar, hints, and the status panel.
render() {
// Erase last frame with the shared theme background, then redraw from scratch.
BT.clear(this.theme.bg);
// The full-width title strip along the top edge.
ui.begin(UI_ANCHORS.TOP_BAR);
ui.panel('Gamepad Input - sticks, triggers, button masks');
ui.end();
this.renderHints();
this.renderArena();
this.renderStatusPanel();
this.renderPodPanel();
// When no gamepad is plugged in yet, show a friendly prompt inside the arena.
if (!BT.isGamepadConnected(PLAYER)) {
this.renderEmptyState();
}
}
// Returns the palette slot of the current pod color.
currentPodColor() {
if (this.podColorIndex === 0) {
return C_POD_A;
}
if (this.podColorIndex === 1) {
return C_POD_B;
}
return C_POD_C;
}
// Converts trigger pressure (0 to 1) into the pod's size in whole pixels.
currentPodSize(throttle) {
return POD_BASE_SIZE + Math.round(throttle * POD_MAX_EXTRA_SIZE);
}
// Returns half of the rendered pod size, rounded down to whole pixels.
currentPodHalfSize(throttle) {
return Math.floor(this.currentPodSize(throttle) / 2);
}
// Keeps a point inside the play area so that a square of the given `radius` around it
// never pokes through the arena border. Returns the clamped position.
clampToArena(pos, radius) {
// The center may come no closer to a wall than its own radius, so the pod's edge
// stops right at the arena border instead of poking through it.
const minX = ARENA_X + radius;
const maxX = ARENA_X + ARENA_W - 1 - radius;
const minY = ARENA_Y + radius;
const maxY = ARENA_Y + ARENA_H - 1 - radius;
// Math.min picks the smaller of two numbers, Math.max the larger; chaining them
// "clamps" a value so it can never leave the min..max range.
const clampedX = Math.max(minX, Math.min(maxX, pos.x));
const clampedY = Math.max(minY, Math.min(maxY, pos.y));
return new Vector2i(clampedX, clampedY);
}
// Puts the pod in the arena center and the aim cursor 28 pixels to its right.
resetPod() {
this.podPos = new Vector2i(ARENA_X + Math.floor(ARENA_W / 2), ARENA_Y + Math.floor(ARENA_H / 2));
this.aimPos = this.podPos.add(new Vector2i(28, 0));
// Snap prevPodPos/prevAimPos to match, so render() does not blend in from
// wherever the pod was before the reset (Start button press).
this.prevPodPos = this.podPos;
this.prevAimPos = this.aimPos;
this.trail = [];
this.boostTicks = 0;
}
// The Controls panel at the top of the left column: what each stick, trigger, and
// button does, as four dim lines.
renderHints() {
ui.begin(UI_ANCHORS.TOP_LEFT, { x: EDGE, y: TOP_Y, width: LEFT_W });
ui.panel('Controls');
ui.label('L stick moves, R stick aims', { color: 'dim' });
ui.label('Triggers grow the pod', { color: 'dim' });
ui.label('A color, B trail, R2 boost', { color: 'dim' });
ui.label('Start recenters', { color: 'dim' });
ui.end();
}
// Draws the arena border, the optional trail, the pod, and the aim cursor.
renderArena() {
// Play area: a filled rectangle with a border so the pod has visible walls.
BT.drawRectFill(new Rect2i(ARENA_X, ARENA_Y, ARENA_W, ARENA_H), C_ARENA);
BT.drawRect(new Rect2i(ARENA_X, ARENA_Y, ARENA_W, ARENA_H), C_ARENA_BORDER);
// Optional breadcrumb trail: one pixel per past pod position when B toggled it
// on.
if (this.trailEnabled) {
for (let i = 0; i < this.trail.length; i++) {
const p = this.trail[i];
BT.drawPixel(p, C_TRAIL);
}
}
// BT.getAxis returns a float from -1 to +1 for sticks, 0 to +1 for triggers. The
// engine applies a "dead zone" first: a stick axis below 0.75 of full travel (the
// default) reads as 0, and the rest is stretched back out to the full range, so
// the pod does not drift when you let go of the stick. We read BOTH triggers and
// keep whichever is squeezed harder (Math.max).
const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));
const size = this.currentPodSize(throttle);
const half = this.currentPodHalfSize(throttle);
// Blend each entity's previous and current tick position by BT.renderAlpha so the
// pod and aim cursor glide smoothly across render frames instead of snapping to a
// new spot only once per tick (see the prevPodPos/prevAimPos comment above).
const pod = Vector2i.lerp(this.prevPodPos, this.podPos, BT.renderAlpha);
const aim = Vector2i.lerp(this.prevAimPos, this.aimPos, BT.renderAlpha);
// Draw the pod as a square centered on its position; the white outline makes it
// pop against the arena.
BT.drawRectFill(new Rect2i(pod.x - half, pod.y - half, size, size), this.currentPodColor());
BT.drawRect(new Rect2i(pod.x - half, pod.y - half, size, size), C_POD_OUTLINE);
// Right-stick aim cursor: a small crosshair so both sticks have visible jobs.
BT.drawLine(new Vector2i(aim.x - 4, aim.y), new Vector2i(aim.x + 4, aim.y), C_AIM);
BT.drawLine(new Vector2i(aim.x, aim.y - 4), new Vector2i(aim.x, aim.y + 4), C_AIM);
}
// The right-hand status panel: connection info, live button pips, raw stick axis
// numbers, and a trigger pressure meter. Everything read here is HELD state or an
// axis value, both safe to read in render(). Button EDGES (BT.isPressed - "did it go
// down just now?") are handled in update() above.
renderStatusPanel() {
// Bitmask OR: BT.BTN_A | BT.BTN_B builds one mask. isDown returns true when
// EITHER button is held - handy for "press any of these" checks in games. The two
// single-button pips above it let you watch the OR happen live.
const aHeld = BT.isDown(BT.BTN_A, PLAYER);
const bHeld = BT.isDown(BT.BTN_B, PLAYER);
const maskHeld = BT.isDown(BT.BTN_A | BT.BTN_B, PLAYER);
// Raw axis values for the readout rows - the same calls the movement code uses.
const moveX = BT.getAxis(BT.AXIS_LEFT_X, PLAYER);
const moveY = BT.getAxis(BT.AXIS_LEFT_Y, PLAYER);
const aimX = BT.getAxis(BT.AXIS_RIGHT_X, PLAYER);
const aimY = BT.getAxis(BT.AXIS_RIGHT_Y, PLAYER);
const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));
// The panel title says how many gamepads the browser reports; the first pip says
// whether player one has one.
ui.begin(UI_ANCHORS.TOP_LEFT, { x: RIGHT_X, y: TOP_Y, width: RIGHT_W });
ui.panel(`Gamepads: ${BT.gamepadCount}`);
ui.pip('P1 connected', BT.isGamepadConnected(PLAYER));
// Pips light up while a button is physically held down right now.
ui.pip('A held', aHeld);
ui.pip('B held', bHeld);
ui.pip('(A|B) mask', maskHeld);
// Lit while ANY gamepad button is held (D-pad, face, shoulders, Start, Select -
// not sticks or triggers). BT.isAnyButtonPressed(player) is the matching edge for
// "press any button".
ui.pip('Any button', BT.isAnyButtonDown(PLAYER));
// The triggers as DIGITAL buttons: held once pulled past half way. The Throttle
// meter below shows the same triggers as an ANALOG 0 to 1 pull.
ui.pip('L2 held', BT.isDown(BT.BTN_L2, PLAYER));
ui.pip('R2 held', BT.isDown(BT.BTN_R2, PLAYER));
// LX/LY are the left stick, RX/RY the right stick, each from -1.00 to +1.00.
ui.kv('LX', formatAxis(moveX));
ui.kv('LY', formatAxis(moveY));
ui.kv('RX', formatAxis(aimX));
ui.kv('RY', formatAxis(aimY));
// The meter fills left-to-right with trigger pressure (0 = released, 1 = fully
// squeezed) - the same value that grows the pod.
ui.meter('Throttle', throttle, { width: METER_W });
ui.end();
}
// The Pod panel under the status panel: what A, B, and R2 have done to the pod. These
// work from the keyboard too (Space or B for A, N for B), so this panel reacts even
// without a gamepad - except the boost, which needs R2.
renderPodPanel() {
ui.begin(UI_ANCHORS.TOP_LEFT, { x: RIGHT_X, y: POD_PANEL_Y, width: RIGHT_W });
ui.panel('Pod');
ui.kv('Color', POD_COLOR_NAMES[this.podColorIndex]);
ui.kv('Trail', this.trailEnabled ? 'on' : 'off');
// The boost meter is full the tick R2 starts a boost and empties as boostTicks
// counts down to 0.
ui.meter('Boost', this.boostTicks / BOOST_TICKS, { width: METER_W });
ui.end();
}
// The "no gamepad yet" prompt, shown inside the empty arena as warm kit labels in a
// borderless group. Pinned near the top of the arena so the idle pod and aim
// crosshair (arena center) do not sit on the text.
renderEmptyState() {
ui.begin(UI_ANCHORS.TOP_LEFT, { x: ARENA_X + 6, y: ARENA_Y + 6 });
// ui.hasTouch() turns true after the first touch contact and stays true. Touch
// devices rarely have a gamepad attached, so tell those visitors plainly that
// this page cannot work for them.
if (ui.hasTouch()) {
ui.label('This demo needs a gamepad.', { color: 'warm' });
} else {
ui.label('Connect a gamepad and press', { color: 'warm' });
ui.label('any button to wake it.', { color: 'warm' });
}
ui.end();
}
}
bootstrap(Demo);