From df998c2b485cc5fcd0dba123a7051b3388acf1df Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:11:06 +0200 Subject: [PATCH 1/8] feat: Add an interactive web example gallery deployed to GitHub Pages The old browser demos return as a single-page gallery on the WebAssembly backend: pyramid, domino tower, ball cage, circle stress, blob, bridge, and racer, rendered through a canvas DebugDraw with a dark palette. Every scene supports dragging bodies with a mouse joint, the racer drives with the arrow keys and a following camera, and scenes deep-link by URL hash. deploy-examples.yml builds the gallery with build_runner on pushes to main and publishes it to GitHub Pages (enable the GitHub Actions source under Settings > Pages once). --- .github/workflows/deploy-examples.yml | 43 ++ packages/forge2d/example/README.md | 11 +- packages/forge2d/example/pubspec.yaml | 3 + .../example/web/canvas_debug_draw.dart | 172 ++++++++ packages/forge2d/example/web/index.html | 100 +++++ packages/forge2d/example/web/main.dart | 199 +++++++++ packages/forge2d/example/web/scenes.dart | 398 ++++++++++++++++++ 7 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy-examples.yml create mode 100644 packages/forge2d/example/web/canvas_debug_draw.dart create mode 100644 packages/forge2d/example/web/index.html create mode 100644 packages/forge2d/example/web/main.dart create mode 100644 packages/forge2d/example/web/scenes.dart diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml new file mode 100644 index 00000000..9c4785ce --- /dev/null +++ b/.github/workflows/deploy-examples.yml @@ -0,0 +1,43 @@ +name: deploy-examples + +on: + push: + branches: + - main + paths: + - "packages/forge2d/**" + - ".github/workflows/deploy-examples.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + - uses: bluefireteam/melos-action@v3 + - name: Build the examples + working-directory: packages/forge2d/example + run: dart run build_runner build --release --output build + - uses: actions/upload-pages-artifact@v3 + with: + path: packages/forge2d/example/build/web + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/packages/forge2d/example/README.md b/packages/forge2d/example/README.md index f4673dbc..c97a53a6 100644 --- a/packages/forge2d/example/README.md +++ b/packages/forge2d/example/README.md @@ -1,11 +1,16 @@ # Examples for Forge2D -Runnable console examples of the native-backed API: +An interactive gallery of physics demos running on the WebAssembly backend, +deployed to GitHub Pages from main. Run it locally with: + +```sh +dart run build_runner serve web:8080 --release +``` + +The console examples of the native backend still live in `bin/`: ```sh dart run bin/hello_world.dart dart run bin/car.dart dart run bin/domino_tower.dart ``` - -Browser examples return when the WebAssembly backend lands. diff --git a/packages/forge2d/example/pubspec.yaml b/packages/forge2d/example/pubspec.yaml index 2c97f13f..d3eb5546 100644 --- a/packages/forge2d/example/pubspec.yaml +++ b/packages/forge2d/example/pubspec.yaml @@ -10,6 +10,9 @@ environment: dependencies: forge2d: ^0.14.2+1 + web: ^1.1.1 dev_dependencies: + build_runner: ^2.6.0 + build_web_compilers: ^4.2.0 flame_lint: ^1.4.1 diff --git a/packages/forge2d/example/web/canvas_debug_draw.dart b/packages/forge2d/example/web/canvas_debug_draw.dart new file mode 100644 index 00000000..89a88770 --- /dev/null +++ b/packages/forge2d/example/web/canvas_debug_draw.dart @@ -0,0 +1,172 @@ +import 'dart:js_interop'; +import 'dart:math' as math; + +import 'package:forge2d/forge2d.dart'; +import 'package:web/web.dart'; + +/// Renders the world onto a canvas through the [DebugDraw] interface, with +/// a soft glow-on-dark styling. +class CanvasDebugDraw extends DebugDraw { + CanvasDebugDraw(this._context); + + final CanvasRenderingContext2D _context; + + /// The world point at the center of the view. + Vector2 center = Vector2.zero(); + + /// How many world meters are visible vertically. + double viewHeight = 40; + + double _scale = 20; + double _canvasWidth = 0; + double _canvasHeight = 0; + + /// Prepares the transform for a frame and clears the canvas. + void beginFrame(double canvasWidth, double canvasHeight) { + _canvasWidth = canvasWidth; + _canvasHeight = canvasHeight; + _scale = canvasHeight / viewHeight; + _context.clearRect(0, 0, canvasWidth, canvasHeight); + } + + double _x(double worldX) => _canvasWidth / 2 + (worldX - center.x) * _scale; + double _y(double worldY) => _canvasHeight / 2 - (worldY - center.y) * _scale; + + /// Converts a canvas point to world coordinates. + Vector2 toWorld(double canvasX, double canvasY) => Vector2( + center.x + (canvasX - _canvasWidth / 2) / _scale, + center.y - (canvasY - _canvasHeight / 2) / _scale, + ); + + String _rgba(int color, double alpha) { + final r = (color >> 16) & 0xFF; + final g = (color >> 8) & 0xFF; + final b = color & 0xFF; + return 'rgba($r, $g, $b, $alpha)'; + } + + void _stroke(int color) { + _context + ..strokeStyle = _rgba(color, 0.95).toJS + ..lineWidth = 1.6 + ..stroke(); + } + + void _fill(int color) { + _context + ..fillStyle = _rgba(color, 0.28).toJS + ..fill(); + } + + void _polygonPath(List vertices) { + _context.beginPath(); + for (var i = 0; i < vertices.length; i++) { + final vertex = vertices[i]; + if (i == 0) { + _context.moveTo(_x(vertex.x), _y(vertex.y)); + } else { + _context.lineTo(_x(vertex.x), _y(vertex.y)); + } + } + _context.closePath(); + } + + @override + void drawPolygon(List vertices, int color) { + _polygonPath(vertices); + _stroke(color); + } + + @override + void drawSolidPolygon( + Transform transform, + List vertices, + double radius, + int color, + ) { + _polygonPath([for (final vertex in vertices) transform.apply(vertex)]); + _fill(color); + _stroke(color); + } + + @override + void drawCircle(Vector2 center, double radius, int color) { + _context.beginPath(); + _context.arc(_x(center.x), _y(center.y), radius * _scale, 0, 2 * math.pi); + _stroke(color); + } + + @override + void drawSolidCircle(Transform transform, double radius, int color) { + final position = transform.position; + _context.beginPath(); + _context.arc( + _x(position.x), + _y(position.y), + radius * _scale, + 0, + 2 * math.pi, + ); + _fill(color); + _stroke(color); + // A radius line makes rotation visible. + final edge = transform.apply(Vector2(radius, 0)); + _context.beginPath(); + _context.moveTo(_x(position.x), _y(position.y)); + _context.lineTo(_x(edge.x), _y(edge.y)); + _stroke(color); + } + + @override + void drawSolidCapsule( + Vector2 point1, + Vector2 point2, + double radius, + int color, + ) { + final axis = point2 - point1; + final angle = math.atan2(axis.y, axis.x); + _context.beginPath(); + _context.arc( + _x(point1.x), + _y(point1.y), + radius * _scale, + -angle + math.pi / 2, + -angle + 3 * math.pi / 2, + ); + _context.arc( + _x(point2.x), + _y(point2.y), + radius * _scale, + -angle - math.pi / 2, + -angle + math.pi / 2, + ); + _context.closePath(); + _fill(color); + _stroke(color); + } + + @override + void drawSegment(Vector2 point1, Vector2 point2, int color) { + _context.beginPath(); + _context.moveTo(_x(point1.x), _y(point1.y)); + _context.lineTo(_x(point2.x), _y(point2.y)); + _stroke(color); + } + + @override + void drawPoint(Vector2 point, double size, int color) { + _context.beginPath(); + _context.arc(_x(point.x), _y(point.y), size / 2, 0, 2 * math.pi); + _context.fillStyle = _rgba(color, 0.9).toJS; + _context.fill(); + } + + @override + void drawString(Vector2 point, String text, int color) { + _context + ..fillStyle = _rgba(0xE5E9F5, 0.8).toJS + ..font = '12px ui-sans-serif, system-ui' + ..fillText(text, _x(point.x), _y(point.y)); + } +} diff --git a/packages/forge2d/example/web/index.html b/packages/forge2d/example/web/index.html new file mode 100644 index 00000000..c2513c0f --- /dev/null +++ b/packages/forge2d/example/web/index.html @@ -0,0 +1,100 @@ + + + + + + Forge2D examples + + + + +
+

Forge2D examples

+ +
+ GitHub +
+ +
+
Loading Box2D…
+ + diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart new file mode 100644 index 00000000..c404e6f2 --- /dev/null +++ b/packages/forge2d/example/web/main.dart @@ -0,0 +1,199 @@ +import 'dart:js_interop'; + +import 'package:forge2d/forge2d.dart'; +import 'package:web/web.dart'; + +import 'canvas_debug_draw.dart'; +import 'scenes.dart'; + +Future main() async { + await initializeForge2D(); + document.getElementById('loading')?.remove(); + _App().start(); +} + +void _listen( + EventTarget target, + String type, + void Function(T) handler, +) => target.addEventListener(type, ((Event event) => handler(event as T)).toJS); + +class _App { + _App() + : _canvas = document.getElementById('canvas')! as HTMLCanvasElement, + _hint = document.getElementById('hint')! as HTMLDivElement, + _nav = document.getElementById('nav')! as HTMLDivElement { + _draw = CanvasDebugDraw( + _canvas.getContext('2d')! as CanvasRenderingContext2D, + ); + } + + final HTMLCanvasElement _canvas; + final HTMLDivElement _hint; + final HTMLDivElement _nav; + late final CanvasDebugDraw _draw; + + World? _world; + SceneHooks _hooks = SceneHooks(); + MouseJoint? _mouseJoint; + double _lastTimestamp = 0; + double _accumulator = 0; + + static const _timeStep = 1 / 60; + + void start() { + for (final scene in scenes) { + final button = HTMLButtonElement() + ..textContent = scene.name + ..onClick.listen((_) => _select(scene)); + _nav.append(button); + } + + _listen( + document, + 'keydown', + (event) => _hooks.onKey?.call(event.key, down: true), + ); + _listen( + document, + 'keyup', + (event) => _hooks.onKey?.call(event.key, down: false), + ); + _listen(_canvas, 'pointerdown', _onPointerDown); + _listen(_canvas, 'pointermove', _onPointerMove); + _listen(_canvas, 'pointerup', (_) => _releaseMouseJoint()); + _listen( + _canvas, + 'pointercancel', + (_) => _releaseMouseJoint(), + ); + + final initial = Uri.base.fragment; + _select( + scenes.firstWhere( + (scene) => _slug(scene.name) == initial, + orElse: () => scenes.first, + ), + ); + window.requestAnimationFrame(_frame.toJS); + } + + String _slug(String name) => name.toLowerCase().replaceAll(' ', '-'); + + void _select(Scene scene) { + _releaseMouseJoint(); + _world?.destroy(); + _hooks = SceneHooks(); + _world = World()..let(scene.build, _hooks); + _draw + ..center = Vector2(0, scene.centerY) + ..viewHeight = scene.viewHeight; + _hint.textContent = scene.hint; + window.location.hash = _slug(scene.name); + + final buttons = _nav.querySelectorAll('button'); + for (var i = 0; i < buttons.length; i++) { + final button = buttons.item(i)! as HTMLButtonElement; + button.className = button.textContent == scene.name ? 'active' : ''; + } + } + + void _frame(JSNumber timestamp) { + final now = timestamp.toDartDouble / 1000; + final elapsed = _lastTimestamp == 0 + ? _timeStep + : (now - _lastTimestamp).clamp(0.0, 0.1); + _lastTimestamp = now; + _accumulator += elapsed; + + final world = _world; + if (world != null) { + while (_accumulator >= _timeStep) { + _hooks.onTick?.call(_timeStep); + world.step(_timeStep); + _accumulator -= _timeStep; + } + if (_hooks.cameraFollow case final follow?) { + _draw.center.setFrom(follow()); + } + _resizeCanvas(); + _draw.beginFrame( + _canvas.width.toDouble(), + _canvas.height.toDouble(), + ); + world.draw(_draw); + } + window.requestAnimationFrame(_frame.toJS); + } + + void _resizeCanvas() { + final ratio = window.devicePixelRatio; + final width = (_canvas.clientWidth * ratio).round(); + final height = (_canvas.clientHeight * ratio).round(); + if (_canvas.width != width || _canvas.height != height) { + _canvas + ..width = width + ..height = height; + } + } + + Vector2 _worldPoint(PointerEvent event) { + final rectangle = _canvas.getBoundingClientRect(); + final ratio = window.devicePixelRatio; + return _draw.toWorld( + (event.clientX - rectangle.left) * ratio, + (event.clientY - rectangle.top) * ratio, + ); + } + + void _onPointerDown(PointerEvent event) { + final world = _world; + final anchor = _hooks.anchor; + if (world == null || anchor == null) { + return; + } + final point = _worldPoint(event); + final nearby = world.overlapAabb( + Aabb(point - Vector2(0.1, 0.1), point + Vector2(0.1, 0.1)), + ); + for (final shape in nearby) { + final body = shape.body; + if (body.type == BodyType.dynamic && shape.testPoint(point)) { + _mouseJoint = world.createMouseJoint( + MouseJointDef( + bodyA: anchor, + bodyB: body, + target: point, + hertz: 6, + dampingRatio: 0.8, + maxForce: 1500 * body.mass, + ), + ); + body.isAwake = true; + _canvas.setPointerCapture(event.pointerId); + return; + } + } + } + + void _onPointerMove(PointerEvent event) { + final joint = _mouseJoint; + if (joint != null && joint.isValid) { + joint.target = _worldPoint(event); + joint.bodyB.isAwake = true; + } + } + + void _releaseMouseJoint() { + if (_mouseJoint case final joint? when joint.isValid) { + joint.destroy(); + } + _mouseJoint = null; + } +} + +extension on T { + /// Calls [function] with this value and [argument], enabling cascades. + void let(void Function(T, A) function, A argument) => + function(this, argument); +} diff --git a/packages/forge2d/example/web/scenes.dart b/packages/forge2d/example/web/scenes.dart new file mode 100644 index 00000000..054ee30c --- /dev/null +++ b/packages/forge2d/example/web/scenes.dart @@ -0,0 +1,398 @@ +import 'dart:math' as math; + +import 'package:forge2d/forge2d.dart'; + +/// The example color palette, applied through shape custom colors. +abstract final class Palette { + static const indigo = 0x7C9CFF; + static const sky = 0x38BDF8; + static const amber = 0xFBBF24; + static const rose = 0xFB7185; + static const emerald = 0x34D399; + static const violet = 0xA78BFA; + static const slate = 0x64748B; + + static const dynamics = [indigo, sky, amber, rose, emerald, violet]; + + /// A stable pseudo-random palette color for index [i]. + static int dynamic(int i) => dynamics[i % dynamics.length]; +} + +ShapeDef _solid(int color, {double? friction, double? restitution}) => ShapeDef( + material: SurfaceMaterial( + friction: friction ?? 0.6, + restitution: restitution ?? 0, + customColor: color, + ), +); + +ShapeDef _static() => ShapeDef( + material: SurfaceMaterial(customColor: Palette.slate), +); + +/// Everything a scene sets up beyond bodies: an anchor for mouse dragging, +/// and optional tick and key hooks. +class SceneHooks { + /// A static body scenes register for mouse joints to attach to. + Body? anchor; + + /// Called every fixed step with the step time. + void Function(double timeStep)? onTick; + + /// Called on key down and key up, with `down` telling which. + void Function(String key, {required bool down})? onKey; + + /// When set, the camera centers on the returned world point every frame. + Vector2 Function()? cameraFollow; +} + +/// A selectable demo scene. +class Scene { + const Scene({ + required this.name, + required this.hint, + required this.viewHeight, + required this.centerY, + required this.build, + }); + + final String name; + final String hint; + final double viewHeight; + final double centerY; + final void Function(World world, SceneHooks hooks) build; +} + +/// All demo scenes, in display order. +final scenes = [ + Scene( + name: 'Pyramid', + hint: 'Drag boxes with the pointer', + viewHeight: 26, + centerY: 10, + build: (world, hooks) { + hooks.anchor = _ground(world); + const rows = 14; + var tint = 0; + for (var row = 0; row < rows; row++) { + final count = rows - row; + final y = 0.55 + row * 1.05; + for (var i = 0; i < count; i++) { + final x = (i - (count - 1) / 2) * 1.1; + world + .createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(x, y)), + ) + .createShape( + Polygon.square(0.5), + _solid(Palette.dynamic(tint++)), + ); + } + } + }, + ), + Scene( + name: 'Domino tower', + hint: 'A heavy ball topples the tower; drag the debris around', + viewHeight: 30, + centerY: 8, + build: (world, hooks) { + hooks.anchor = _ground(world); + const rows = 10; + var tint = 0; + for (var row = 0; row < rows; row++) { + final count = rows - row; + final y = 0.45 + row * 0.95; + for (var i = 0; i < count; i++) { + final x = (i - (count - 1) / 2) * 1.1; + world + .createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(x, y)), + ) + .createShape( + Polygon.box(0.05, 0.45), + _solid(Palette.dynamic(tint++)), + ); + } + } + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2(-30, 6), + linearVelocity: Vector2(35, 0), + isBullet: true, + ), + ) + .createShape( + Circle(radius: 0.9), + ShapeDef( + density: 8, + material: SurfaceMaterial(customColor: Palette.rose), + ), + ); + }, + ), + Scene( + name: 'Ball cage', + hint: 'A spinning paddle keeps the balls moving', + viewHeight: 34, + centerY: 0, + build: (world, hooks) { + world.gravity = Vector2.zero(); + final cage = world.createBody(); + hooks.anchor = cage; + const cageRadius = 15.0; + cage.createChain( + ChainDef( + points: [ + for (var i = 0; i < 40; i++) + Vector2( + cageRadius * math.cos(-2 * math.pi * i / 40), + cageRadius * math.sin(-2 * math.pi * i / 40), + ), + ], + isLoop: true, + ), + ); + + final paddle = world.createBody( + BodyDef(type: BodyType.kinematic, angularVelocity: 0.8), + )..createShape(Polygon.box(11, 0.4), _solid(Palette.violet)); + hooks.onTick = (_) => paddle.isAwake = true; + + final random = math.Random(1); + for (var i = 0; i < 60; i++) { + final angle = random.nextDouble() * 2 * math.pi; + final distance = random.nextDouble() * 10 + 2; + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2( + distance * math.cos(angle), + distance * math.sin(angle), + ), + ), + ) + .createShape( + Circle(radius: 0.35 + random.nextDouble() * 0.35), + _solid(Palette.dynamic(i), restitution: 0.7, friction: 0.1), + ); + } + }, + ), + Scene( + name: 'Circle stress', + hint: 'Hundreds of balls, one container', + viewHeight: 34, + centerY: 12, + build: (world, hooks) { + hooks.anchor = _ground(world, width: 18); + for (final x in [-18.0, 18.0]) { + world + .createBody(BodyDef(position: Vector2(x, 12))) + .createShape(Polygon.box(0.5, 14), _static()); + } + final random = math.Random(7); + for (var i = 0; i < 320; i++) { + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2( + random.nextDouble() * 30 - 15, + 6 + random.nextDouble() * 20, + ), + ), + ) + .createShape( + Circle(radius: 0.25 + random.nextDouble() * 0.5), + _solid( + Palette.dynamic(i), + restitution: 0.35, + friction: 0.2, + ), + ); + } + }, + ), + Scene( + name: 'Blob', + hint: 'A soft body of springs; drag it around', + viewHeight: 26, + centerY: 8, + build: (world, hooks) { + hooks.anchor = _ground(world); + const segments = 24; + const blobRadius = 3.5; + final center = Vector2(0, 12); + final ring = [ + for (var i = 0; i < segments; i++) + world.createBody( + BodyDef( + type: BodyType.dynamic, + position: + center + + Vector2( + blobRadius * math.cos(2 * math.pi * i / segments), + blobRadius * math.sin(2 * math.pi * i / segments), + ), + fixedRotation: true, + ), + )..createShape( + Circle(radius: 0.6), + _solid(Palette.emerald, friction: 0.9), + ), + ]; + DistanceJointDef spring(Body a, Body b) => DistanceJointDef( + bodyA: a, + bodyB: b, + length: (a.position - b.position).length, + enableSpring: true, + hertz: 6, + dampingRatio: 0.4, + ); + for (var i = 0; i < segments; i++) { + world + ..createDistanceJoint(spring(ring[i], ring[(i + 1) % segments])) + ..createDistanceJoint( + spring(ring[i], ring[(i + segments ~/ 2) % segments]), + ); + } + }, + ), + Scene( + name: 'Bridge', + hint: 'A plank bridge under load', + viewHeight: 24, + centerY: 6, + build: (world, hooks) { + final left = world.createBody(BodyDef(position: Vector2(-14, 6))) + ..createShape(Polygon.box(0.6, 6), _static()); + final right = world.createBody(BodyDef(position: Vector2(14, 6))) + ..createShape(Polygon.box(0.6, 6), _static()); + hooks.anchor = left; + + const planks = 20; + var previous = left; + var anchorOnPrevious = Vector2(0.6, 5.6); + for (var i = 0; i < planks; i++) { + final x = -13.4 + 26.8 * (i + 0.5) / planks; + final plank = + world.createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(x, 11.6)), + )..createShape( + Polygon.box(26.8 / planks / 2 - 0.05, 0.15), + _solid(Palette.amber, friction: 0.8), + ); + world.createRevoluteJoint( + RevoluteJointDef( + bodyA: previous, + bodyB: plank, + localAnchorA: anchorOnPrevious, + localAnchorB: Vector2(-26.8 / planks / 2, 0), + ), + ); + previous = plank; + anchorOnPrevious = Vector2(26.8 / planks / 2, 0); + } + world.createRevoluteJoint( + RevoluteJointDef( + bodyA: previous, + bodyB: right, + localAnchorA: anchorOnPrevious, + localAnchorB: Vector2(-0.6, 5.6), + ), + ); + + final random = math.Random(3); + for (var i = 0; i < 8; i++) { + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2(random.nextDouble() * 16 - 8, 16 + i * 1.5), + ), + ) + .createShape( + i.isEven + ? Circle(radius: 0.7) + : Polygon.square(0.6) as ShapeGeometry, + _solid(Palette.dynamic(i), friction: 0.4), + ); + } + }, + ), + Scene( + name: 'Racer', + hint: 'Drive with the left and right arrow keys', + viewHeight: 24, + centerY: 3, + build: (world, hooks) { + // Rolling terrain; chains are one-sided, so the points run right to + // left for ground driven on from above. + final ground = world.createBody(); + hooks.anchor = ground; + ground.createChain( + ChainDef( + points: [ + for (var x = 400.0; x >= -30.0; x -= 2) + Vector2(x, math.sin(x / 9) * 1.8 + x * 0.015), + ], + ), + ); + + final chassis = world.createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(0, 4)), + )..createShape(Polygon.box(1.4, 0.35), _solid(Palette.indigo)); + + Body wheel(double x) => + world.createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(x, 3.5)), + )..createShape( + Circle(radius: 0.45), + ShapeDef( + material: SurfaceMaterial( + friction: 1.6, + customColor: Palette.rose, + ), + ), + ); + + final joints = [ + for (final x in [-1.0, 1.0]) + world.createWheelJoint( + WheelJointDef( + bodyA: chassis, + bodyB: wheel(x), + localAnchorA: Vector2(x, -0.35), + hertz: 4, + enableMotor: true, + maxMotorTorque: 40, + ), + ), + ]; + + var throttle = 0.0; + hooks + ..cameraFollow = (() => chassis.position + Vector2(0, 3)) + ..onKey = (key, {required bool down}) { + if (key == 'ArrowRight') { + throttle = down ? -30 : 0; + } else if (key == 'ArrowLeft') { + throttle = down ? 30 : 0; + } + } + ..onTick = (_) { + for (final joint in joints) { + joint.motorSpeed = throttle; + } + }; + }, + ), +]; + +Body _ground(World world, {double width = 40}) => + world.createBody(BodyDef(position: Vector2(0, -1))) + ..createShape(Polygon.box(width, 1), _static()); From f4aea51c2545cf0d825ac298c3c236a79bbcb453 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:18:40 +0200 Subject: [PATCH 2/8] feat: Rebuild the domino tower properly and add scene resets The tower now uses the classic construction from the old demo: vertical dominoes carrying horizontal planks level by level with braces at the edges and tapering density, 25 levels tall. It stands perfectly still until a heavy ball, launched two seconds in from a random side, height, and speed, knocks it down. Verified deterministically on the VM: the top of the tower keeps its height through the standing phase and falls from y=34 to y=6 after impact. Every scene also gains a Reset button in the header, which rebuilds the current scene (and re-rolls the domino ball trajectory). --- packages/forge2d/example/web/index.html | 12 ++ packages/forge2d/example/web/main.dart | 7 ++ packages/forge2d/example/web/scenes.dart | 142 +++++++++++++++++------ 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/packages/forge2d/example/web/index.html b/packages/forge2d/example/web/index.html index c2513c0f..c698c360 100644 --- a/packages/forge2d/example/web/index.html +++ b/packages/forge2d/example/web/index.html @@ -59,6 +59,17 @@ color: #0b1020; font-weight: 600; } + #reset { + background: transparent; + color: var(--text); + border: 1px solid #2a3355; + border-radius: 999px; + padding: 0.3rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s ease; + } + #reset:hover { border-color: var(--accent); color: var(--accent); } #canvas { display: block; width: 100vw; height: calc(100vh - 53px); } #hint { position: fixed; @@ -91,6 +102,7 @@

Forge2D examples

+
GitHub diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart index c404e6f2..5e4a5689 100644 --- a/packages/forge2d/example/web/main.dart +++ b/packages/forge2d/example/web/main.dart @@ -35,6 +35,7 @@ class _App { World? _world; SceneHooks _hooks = SceneHooks(); + Scene _currentScene = scenes.first; MouseJoint? _mouseJoint; double _lastTimestamp = 0; double _accumulator = 0; @@ -59,6 +60,11 @@ class _App { 'keyup', (event) => _hooks.onKey?.call(event.key, down: false), ); + _listen( + document.getElementById('reset')!, + 'click', + (_) => _select(_currentScene), + ); _listen(_canvas, 'pointerdown', _onPointerDown); _listen(_canvas, 'pointermove', _onPointerMove); _listen(_canvas, 'pointerup', (_) => _releaseMouseJoint()); @@ -83,6 +89,7 @@ class _App { void _select(Scene scene) { _releaseMouseJoint(); _world?.destroy(); + _currentScene = scene; _hooks = SceneHooks(); _world = World()..let(scene.build, _hooks); _draw diff --git a/packages/forge2d/example/web/scenes.dart b/packages/forge2d/example/web/scenes.dart index 054ee30c..852ce115 100644 --- a/packages/forge2d/example/web/scenes.dart +++ b/packages/forge2d/example/web/scenes.dart @@ -94,43 +94,119 @@ final scenes = [ Scene( name: 'Domino tower', hint: 'A heavy ball topples the tower; drag the debris around', - viewHeight: 30, - centerY: 8, + viewHeight: 46, + centerY: 17, build: (world, hooks) { - hooks.anchor = _ground(world); - const rows = 10; + hooks.anchor = _ground(world, width: 45); + + // The classic domino tower: vertical dominoes carry horizontal + // dominoes as planks, level by level, with braces at the edges. + const dominoWidth = 0.2; + const dominoHeight = 1.0; + const baseCount = 25; + var dominoDensity = 10.0; var tint = 0; - for (var row = 0; row < rows; row++) { - final count = rows - row; - final y = 0.45 + row * 0.95; - for (var i = 0; i < count; i++) { - final x = (i - (count - 1) / 2) * 1.1; - world - .createBody( - BodyDef(type: BodyType.dynamic, position: Vector2(x, y)), - ) - .createShape( - Polygon.box(0.05, 0.45), - _solid(Palette.dynamic(tint++)), - ); - } + + void makeDomino(double x, double y, {required bool horizontal}) { + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2(x, y), + rotation: horizontal + ? Rot.fromAngle(math.pi / 2) + : const Rot.identity(), + ), + ) + .createShape( + Polygon.box(dominoWidth / 2, dominoHeight / 2), + ShapeDef( + density: dominoDensity, + material: SurfaceMaterial( + friction: 0.1, + restitution: 0.65, + customColor: Palette.dynamic(tint++), + ), + ), + ); } - world - .createBody( - BodyDef( - type: BodyType.dynamic, - position: Vector2(-30, 6), - linearVelocity: Vector2(35, 0), - isBullet: true, - ), - ) - .createShape( - Circle(radius: 0.9), - ShapeDef( - density: 8, - material: SurfaceMaterial(customColor: Palette.rose), - ), + + for (var i = 0; i < baseCount; ++i) { + final x = i * 1.5 * dominoHeight - 1.5 * dominoHeight * baseCount / 2; + makeDomino(x, dominoHeight / 2, horizontal: false); + makeDomino(x, dominoHeight + dominoWidth / 2, horizontal: true); + } + for (var level = 1; level < baseCount; ++level) { + if (level > 3) { + dominoDensity *= 0.8; + } + final y = + dominoHeight * 0.5 + + (dominoHeight + 2 * dominoWidth) * 0.99 * level; + final count = baseCount - level; + for (var i = 0; i < count; ++i) { + final x = i * 1.5 * dominoHeight - 1.5 * dominoHeight * count / 2; + dominoDensity *= 2.5; + if (i == 0) { + makeDomino( + x - 1.25 * dominoHeight + 0.5 * dominoWidth, + y - dominoWidth, + horizontal: false, + ); + } + if (i == count - 1) { + makeDomino( + x + 1.25 * dominoHeight - 0.5 * dominoWidth, + y - dominoWidth, + horizontal: false, + ); + } + dominoDensity /= 2.5; + makeDomino(x, y, horizontal: false); + makeDomino( + x, + y + 0.5 * (dominoWidth + dominoHeight), + horizontal: true, + ); + makeDomino( + x, + y - 0.5 * (dominoWidth + dominoHeight), + horizontal: true, ); + } + } + + // The ball launches once the tower has had a moment to stand, from a + // random side, height, and speed on every run. + final random = math.Random(); + final side = random.nextBool() ? 1 : -1; + final height = 8 + random.nextDouble() * 16; + final speed = 42 + random.nextDouble() * 18; + var elapsed = 0.0; + var launched = false; + hooks.onTick = (timeStep) { + elapsed += timeStep; + if (launched || elapsed < 2) { + return; + } + launched = true; + world + .createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2(-side * 45, height), + linearVelocity: Vector2(side * speed, 0), + isBullet: true, + ), + ) + .createShape( + Circle(radius: 1.4), + ShapeDef( + density: 10, + material: SurfaceMaterial(customColor: Palette.rose), + ), + ); + }; }, ), Scene( From 267ebd0589c44204ead9619e95610b918bbfd7ab Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:20:57 +0200 Subject: [PATCH 3/8] fix: Keep the racer awake so keyboard input always applies The car fell asleep after landing and setting a wheel joint's motor speed does not wake sleeping bodies, so arrow keys appeared dead. The chassis and wheels now have sleep disabled. Verified on the VM: after three seconds of idling, two seconds of ArrowRight drives the car 14 meters. --- packages/forge2d/example/web/scenes.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/forge2d/example/web/scenes.dart b/packages/forge2d/example/web/scenes.dart index 852ce115..fa6af127 100644 --- a/packages/forge2d/example/web/scenes.dart +++ b/packages/forge2d/example/web/scenes.dart @@ -419,13 +419,23 @@ final scenes = [ ), ); + // The car never sleeps: joint motor changes alone do not wake + // sleeping bodies, which would swallow the first keyboard input. final chassis = world.createBody( - BodyDef(type: BodyType.dynamic, position: Vector2(0, 4)), + BodyDef( + type: BodyType.dynamic, + position: Vector2(0, 4), + enableSleep: false, + ), )..createShape(Polygon.box(1.4, 0.35), _solid(Palette.indigo)); Body wheel(double x) => world.createBody( - BodyDef(type: BodyType.dynamic, position: Vector2(x, 3.5)), + BodyDef( + type: BodyType.dynamic, + position: Vector2(x, 3.5), + enableSleep: false, + ), )..createShape( Circle(radius: 0.45), ShapeDef( From cc593b6b77400b145afb18cb4a71a4f36a7d0951 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:22:11 +0200 Subject: [PATCH 4/8] refactor: Use an enum for DOM event types in the examples app Event listeners now register through a DomEvent enum instead of raw strings, making the event names typo-proof at the call sites. --- packages/forge2d/example/web/main.dart | 41 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart index 5e4a5689..30821d6e 100644 --- a/packages/forge2d/example/web/main.dart +++ b/packages/forge2d/example/web/main.dart @@ -12,11 +12,30 @@ Future main() async { _App().start(); } +/// The DOM events the app listens to, with their event type names. +enum DomEvent { + keyDown('keydown'), + keyUp('keyup'), + pointerDown('pointerdown'), + pointerMove('pointermove'), + pointerUp('pointerup'), + pointerCancel('pointercancel'), + click('click'); + + const DomEvent(this.type); + + /// The DOM event type name. + final String type; +} + void _listen( + DomEvent event, EventTarget target, - String type, void Function(T) handler, -) => target.addEventListener(type, ((Event event) => handler(event as T)).toJS); +) => target.addEventListener( + event.type, + ((Event domEvent) => handler(domEvent as T)).toJS, +); class _App { _App() @@ -51,26 +70,30 @@ class _App { } _listen( + DomEvent.keyDown, document, - 'keydown', (event) => _hooks.onKey?.call(event.key, down: true), ); _listen( + DomEvent.keyUp, document, - 'keyup', (event) => _hooks.onKey?.call(event.key, down: false), ); _listen( + DomEvent.click, document.getElementById('reset')!, - 'click', (_) => _select(_currentScene), ); - _listen(_canvas, 'pointerdown', _onPointerDown); - _listen(_canvas, 'pointermove', _onPointerMove); - _listen(_canvas, 'pointerup', (_) => _releaseMouseJoint()); + _listen(DomEvent.pointerDown, _canvas, _onPointerDown); + _listen(DomEvent.pointerMove, _canvas, _onPointerMove); + _listen( + DomEvent.pointerUp, + _canvas, + (_) => _releaseMouseJoint(), + ); _listen( + DomEvent.pointerCancel, _canvas, - 'pointercancel', (_) => _releaseMouseJoint(), ); From 44ebb06146b1d74de36ece825b01d8ebc1d53f5e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:23:32 +0200 Subject: [PATCH 5/8] refactor: Derive DOM event names from the enum value names Every DOM event name is the lowercase of its camelCase Dart name, so the constructor parameter goes away in favor of a name-based getter. --- packages/forge2d/example/web/main.dart | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart index 30821d6e..46c875d5 100644 --- a/packages/forge2d/example/web/main.dart +++ b/packages/forge2d/example/web/main.dart @@ -12,20 +12,18 @@ Future main() async { _App().start(); } -/// The DOM events the app listens to, with their event type names. +/// The DOM events the app listens to. enum DomEvent { - keyDown('keydown'), - keyUp('keyup'), - pointerDown('pointerdown'), - pointerMove('pointermove'), - pointerUp('pointerup'), - pointerCancel('pointercancel'), - click('click'); - - const DomEvent(this.type); + keyDown, + keyUp, + pointerDown, + pointerMove, + pointerUp, + pointerCancel, + click; /// The DOM event type name. - final String type; + String get type => name.toLowerCase(); } void _listen( From 8c0f054472046f76cffb2f071d5ecde50c51878a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:40:42 +0200 Subject: [PATCH 6/8] ci: Publish the examples to the gh-pages branch with real files Deploying through the gh-pages branch works before the workflow reaches the default branch, and materializing build_runner's symlinked packages directory fixes the missing assets that broke both the artifact upload (tar: File removed before we read it) and the branch deploy. --- .github/workflows/deploy-examples.yml | 37 ++++++++++++++------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index 9c4785ce..73bccdf1 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -10,16 +10,14 @@ on: workflow_dispatch: permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: group: pages cancel-in-progress: true jobs: - build: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -27,17 +25,20 @@ jobs: - uses: bluefireteam/melos-action@v3 - name: Build the examples working-directory: packages/forge2d/example - run: dart run build_runner build --release --output build - - uses: actions/upload-pages-artifact@v3 - with: - path: packages/forge2d/example/build/web - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + run: | + dart run build_runner build --release --output build + # The output's packages directory is a symlink; materialize it so + # the published branch carries real files. + cp -rL build/web site + - name: Publish to the gh-pages branch + working-directory: packages/forge2d/example/site + run: | + touch .nojekyll + git init -b gh-pages + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Deploy forge2d examples for $GITHUB_SHA" + git push --force \ + "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" \ + gh-pages From e1f0cbdcb1b0c308a5ddbd12f10e6f611db84ccd Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 19 Jul 2026 23:58:31 +0200 Subject: [PATCH 7/8] feat: Render the gallery from shape geometry read-back Shapes are now drawn from Shape.geometry with their body transforms, the same pattern a game renderer such as flame_forge2d uses, with the camera rectangle as an overlapAabb culling query. Joints still come from the debug draw with shape drawing disabled. Shape colors travel through userData, so the inline shape definitions gained explicit colors. --- packages/forge2d/example/web/main.dart | 51 +++++++++++++++++++++--- packages/forge2d/example/web/scenes.dart | 6 +++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart index 46c875d5..ab4a5468 100644 --- a/packages/forge2d/example/web/main.dart +++ b/packages/forge2d/example/web/main.dart @@ -145,15 +145,56 @@ class _App { _draw.center.setFrom(follow()); } _resizeCanvas(); - _draw.beginFrame( - _canvas.width.toDouble(), - _canvas.height.toDouble(), - ); - world.draw(_draw); + _draw.beginFrame(_canvas.width.toDouble(), _canvas.height.toDouble()); + _renderShapes(world); + // Joints still come from the debug draw; shape drawing is off because + // _renderShapes draws them from their geometry. + world.draw(_draw..drawShapes = false); } window.requestAnimationFrame(_frame.toJS); } + /// Renders every shape in view from its read-back geometry and its + /// body's transform, the same pattern a game renderer uses. The camera + /// rectangle doubles as a culling query. + void _renderShapes(World world) { + final viewHalfHeight = _draw.viewHeight / 2; + final viewHalfWidth = + viewHalfHeight * (_canvas.width / _canvas.height.clamp(1, 1 << 16)); + final margin = Vector2(viewHalfWidth + 5, viewHalfHeight + 5); + final view = Aabb(_draw.center - margin, _draw.center + margin); + + for (final shape in world.overlapAabb(view)) { + final transform = shape.body.transform; + final color = shape.userData is int + ? shape.userData! as int + : Palette.slate; + switch (shape.geometry) { + case Circle(:final center, :final radius): + _draw.drawSolidCircle( + Transform(transform.apply(center), transform.rotation), + radius, + color, + ); + case Capsule(:final center1, :final center2, :final radius): + _draw.drawSolidCapsule( + transform.apply(center1), + transform.apply(center2), + radius, + color, + ); + case Segment(:final point1, :final point2): + _draw.drawSegment( + transform.apply(point1), + transform.apply(point2), + color, + ); + case Polygon(:final points, :final radius): + _draw.drawSolidPolygon(transform, points!, radius, color); + } + } + } + void _resizeCanvas() { final ratio = window.devicePixelRatio; final width = (_canvas.clientWidth * ratio).round(); diff --git a/packages/forge2d/example/web/scenes.dart b/packages/forge2d/example/web/scenes.dart index fa6af127..0536fe6f 100644 --- a/packages/forge2d/example/web/scenes.dart +++ b/packages/forge2d/example/web/scenes.dart @@ -19,6 +19,8 @@ abstract final class Palette { } ShapeDef _solid(int color, {double? friction, double? restitution}) => ShapeDef( + // The renderer reads the color from the shape's user data. + userData: color, material: SurfaceMaterial( friction: friction ?? 0.6, restitution: restitution ?? 0, @@ -27,6 +29,7 @@ ShapeDef _solid(int color, {double? friction, double? restitution}) => ShapeDef( ); ShapeDef _static() => ShapeDef( + userData: Palette.slate, material: SurfaceMaterial(customColor: Palette.slate), ); @@ -121,6 +124,7 @@ final scenes = [ .createShape( Polygon.box(dominoWidth / 2, dominoHeight / 2), ShapeDef( + userData: Palette.dynamics[tint % Palette.dynamics.length], density: dominoDensity, material: SurfaceMaterial( friction: 0.1, @@ -202,6 +206,7 @@ final scenes = [ .createShape( Circle(radius: 1.4), ShapeDef( + userData: Palette.rose, density: 10, material: SurfaceMaterial(customColor: Palette.rose), ), @@ -439,6 +444,7 @@ final scenes = [ )..createShape( Circle(radius: 0.45), ShapeDef( + userData: Palette.rose, material: SurfaceMaterial( friction: 1.6, customColor: Palette.rose, From ca399fa57f2207be2362421e0cc36f10105c864f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 00:04:55 +0200 Subject: [PATCH 8/8] feat: Use the Forge2D logo in the example gallery The header shows the flaming anvil next to the title and the SVG doubles as the favicon, copied from design/only-logo.svg. --- packages/forge2d/example/web/index.html | 7 ++++++- packages/forge2d/example/web/logo.svg | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/forge2d/example/web/logo.svg diff --git a/packages/forge2d/example/web/index.html b/packages/forge2d/example/web/index.html index c698c360..701051fb 100644 --- a/packages/forge2d/example/web/index.html +++ b/packages/forge2d/example/web/index.html @@ -4,6 +4,7 @@ Forge2D examples +