npm.io
0.1.1 • Published 1h ago

@hazumi/physics

Licence
MIT
Version
0.1.1
Deps
2
Size
5 kB
Vulns
0
Weekly
0

Hazumi

A typed 2D graphics library for sketches, generative work, and games.

Draw with ordinary functions. The same scene can run on WebGL2, export as SVG, or record commands in a unit test. Colour is OKLCH. Input, a camera, sprites, paths, text, physics, and audio are all there.

0.1.0, pre-alpha.

Start a project

Site: hazumi-eta.vercel.app

bun create hazumi

The wizard asks for a name and whether you want a sketch (draw) or a game (update + draw). vite build writes a static dist/ you can zip for itch.io or GitHub Pages.

A scene

import { start } from "hazumi/app";
import { webgl2 } from "hazumi/backends/webgl2";
import { background, circle, fill, oklch } from "hazumi/draw";
import { screen, time } from "hazumi/scene";

start({ backend: webgl2(), width: 600, height: 600 }, () => {
  return {
    draw() {
      background(oklch(0.15, 0.02, 260));
      fill(oklch(0.7, 0.18, 250));
      circle(screen.width / 2, screen.height / 2, 200 + Math.sin(time.elapsed) * 80);
    },
  };
});

Import by capability: hazumi/draw, hazumi/input, hazumi/scene, hazumi/assets, hazumi/audio, hazumi/math, hazumi/color. Functions do the work; live values sit on objects (screen.width, time.elapsed, input.mouseX). They resolve to the application that is currently running.

The scene factory still receives the context, which is where plugin APIs such as audio live. After an await in the factory, capability imports are inactive until the returned callbacks run — finish async setup from the context argument.

Pass --auto-import to create-hazumi for *.scene.ts files that skip the capability imports. The Vite plugin inserts them at build time.

Colours are values: oklch(l, c, h) or rgb(r, g, b). A CSS string still parses if you paste one.

Style can be scoped instead of pushed and popped. It restores even if the body throws:

scoped({ fill: oklch(0.65, 0.22, 20), stroke: null }, () => {
  drawPetals();
});

A game

Split simulation from drawing. update runs at a fixed step; draw follows the display and receives an interpolation alpha:

import { background, circle, fill, oklch } from "hazumi/draw";
import { keyIsDown, keyJustPressed } from "hazumi/input";

start({ backend: webgl2(), clock: { fixedStep: 1 / 60 } }, () => {
  let previousX = 100;
  let x = 100;

  return {
    update(dt) {
      previousX = x;
      if (keyIsDown("ArrowRight")) x += 120 * dt;
      if (keyJustPressed(" ")) jump();
    },
    draw(alpha) {
      background(oklch(0.12, 0.02, 260));
      fill(oklch(0.7, 0.12, 250));
      circle(previousX + (x - previousX) * alpha, 300, 32);
    },
  };
});

Catch-up is capped, so a backgrounded tab cannot pile up unbounded simulation steps. Override clock.maxDelta and clock.maxFixedSteps if you need different limits.

Input

keyJustPressed, keyJustReleased, pointerJustPressed, and pointerJustReleased last for one fixed update. A tap that finishes between updates still reports both edges; key repeat does not. pointers is mouse, pen, and every current touch, in logical canvas coordinates. A released contact stays in the list for that update so its last position is available. wheelX / wheelY accumulate once per update, in CSS pixels.

mouseX, mouseY, mouseIsPressed, mouseJustPressed, and mouseJustReleased alias the primary pointer.

Gamepads are polled at the start of each fixed update: gamepads for axes and analog buttons, plus gamepadButtonIsDown / JustPressed / JustReleased. Disconnecting a held pad reports release edges before it leaves the list.

Camera

Every scene has a camera. Its position is the world point shown at the centre of the canvas:

import { background, circle, oklch, text } from "hazumi/draw";
import { camera } from "hazumi/scene";

return {
  update(dt) {
    movePlayer(dt);
    camera.follow(player.x, player.y, 0.12);
    camera.setZoom(2);
  },
  draw(alpha) {
    background(oklch(0.12, 0.02, 260));
    circle(player.x, player.y, 32);

    camera.screen(() => {
      text("HP 100", 16, 24);
    });
  },
};

background always covers the screen. camera.screen() draws HUD in canvas coordinates. screenToWorld() and worldToScreen() convert pointer and world points; both take an optional output object.

Sprites

const sheet = spritesheet(await loadImage("tiles.png"), { frame: [16, 16] });
image(sheet.at(3, 1), x, y);

image() takes a frame wherever it takes an image. Frames are reused by reference, so asking for the same cell every frame allocates nothing. Indices wrap, so frame(t) loops.

Clips live on the sheet:

const hero = spritesheet(await loadImage("hero.png"), {
  frame: [16, 24],
  clips: {
    idle: { frames: [0, 1, 2, 3], fps: 6 },
    run: { frames: [8, 9, 10, 11, 12, 13], fps: 14 },
    jump: { frames: [16], end: ClipEnd.Hold },
  },
});

image(hero.clip("run").at(time.elapsed), x, y);

at(seconds) is a pure function of time. Several entities can share a clip. Clips loop, hold, or ping-pong; repeat a frame index to hold it longer.

Batching follows draw order. Sprites from one sheet drawn together are one draw call; mixing sheets costs one call per switch. app.stats.drawCalls reports the last frame.

Tilemaps

const world = tilemap({
  columns: 64,
  rows: 32,
  tileWidth: 16,
  tileHeight: 16,
  layers: [
    { name: "ground", sheet, tiles: groundTiles },
    { name: "detail", sheet, tiles: detailTiles },
  ],
});

return {
  update() {
    camera.follow(player.x, player.y, 0.12);
  },
  draw() {
    world.draw();
  },
};

Use EMPTY_TILE for gaps. world.layer("detail").set(x, y, frame) edits a cell in place. Invalid frame indices throw.

Paths

beginShape();
vertex(0, 0);
bezierVertex(70, -40, 140, -60, 200, 0);
bezierVertex(140, 60, 70, 40, 0, 0);
endShape(true);

SVG export keeps the curves as curve commands.

Text

textFont("Georgia");
textSize(24);
text("hello", 16, 32);

Uses fonts already on the system. Size and font persist across frames until you change them.

Shaders

A pass is a main(). The runtime provides v_uv, fragColor, u_texture, u_resolution, u_time, and texelSize():

setPasses([
  {
    fragment: `void main() {
      vec4 c = texture(u_texture, v_uv);
      fragColor = vec4(1.0 - c.rgb, c.a);
    }`,
  },
]);

Passes run in order. A scene that never sets any allocates nothing for them.

Canvas size and pixels

resize() changes the logical size and the backing store together. width, height, and pixelRatio are on the context. If you do not pin a ratio in start(), moving between displays updates the backing store.

const app = start({ backend: webgl2(), width: 320, height: 180 }, scene);
await app.ready;

app.resize(640, 360);
const pixels = app.loadPixels();
pixels.set(0, 0, [255, 0, 255, 255]);
app.updatePixels(pixels);

const png = await app.capturePng();

Pixels.get() and set() address physical pixels. SVG and headless throw PixelAccessUnavailableError.

Collision

import { collision, vec2 } from "hazumi/math";

const hit = collision.sweepAabb(collision.aabb(x, y, 24, 24), vec2.vec2(vx * dt, vy * dt), wall);

if (hit) {
  x += vx * dt * hit.time;
  y += vy * dt * hit.time;
}

Point containment, AABB/circle overlap, raycasts, and circle sweeps use the same names. createRayHit() and createSweepHit() give reusable results.

slideAabb moves against a list of solid AABBs one axis at a time, and skips null holes:

const out = { x: 0, y: 0 };
collision.slideAabb(player, vx * dt, vy * dt, walls, out);
player.x += out.x;
player.y += out.y;

Pathfinding

A cost grid, separate from a tilemap. 0 is blocked, 1 is a normal step:

import { pathfind } from "hazumi/math";

const map = pathfind.grid(32, 18);
map.set(4, 5, 0);
const path = pathfind.createPath();
pathfind.astar(map, 1, 1, 30, 16, { out: path });

Returns cell coordinates from start through goal, or null. { diagonal: true } allows 8-direction movement without cutting a blocked corner. Reuse the grid and the path.

Physics

Circles and oriented boxes, with restitution and friction. The usual way to run it is the plugin, which steps after each fixed update:

import { createPluginHost, start } from "hazumi/app";
import { overlay } from "hazumi/debug";
import { physics } from "hazumi/physics";

start(
  {
    backend: webgl2(),
    plugins: createPluginHost()
      .use(physics({ gravityY: 1400 }))
      .use(overlay()),
  },
  ({ physics }) => {
    physics.world.addBox({ x: 300, y: 580, width: 600, height: 24, isStatic: true });
    const ball = physics.world.addCircle({ x: 300, y: 80, radius: 18, restitution: 0.7 });
    return {
      draw() {
        circle(ball.x, ball.y, ball.radius * 2);
      },
    };
  },
);

Do not also call world.step — the host already did. overlay() draws stats and body outlines after the scene; toggleKey: "F1" makes it dismissible.

import { physics } from "hazumi/math" is the solver on its own, if you want to step it yourself. Platformers that slide on tiles still use slideAabb.

Backends

Import Role
hazumi/backends/webgl2 Default renderer
hazumi/backends/canvas2d 2D canvas
hazumi/backends/svg Vector export
hazumi/backends/headless Recorded command stream for tests

Try it

bun install
bun run build
bun run dev
Page
/ Landing page
/playground Live editor
/reference API reference
/examples Example scenes

Requires Bun 1.3+.

Site

apps/web, deployed on Vercel from main.

License

MIT