diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml new file mode 100644 index 00000000..73bccdf1 --- /dev/null +++ b/.github/workflows/deploy-examples.yml @@ -0,0 +1,44 @@ +name: deploy-examples + +on: + push: + branches: + - main + paths: + - "packages/forge2d/**" + - ".github/workflows/deploy-examples.yml" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + 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 + # 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 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..701051fb --- /dev/null +++ b/packages/forge2d/example/web/index.html @@ -0,0 +1,117 @@ + + + + + + Forge2D examples + + + + + +
+

Forge2D logoForge2D examples

+ +
+ + GitHub +
+ +
+
Loading Box2D…
+ + diff --git a/packages/forge2d/example/web/logo.svg b/packages/forge2d/example/web/logo.svg new file mode 100644 index 00000000..c7bc8bc0 --- /dev/null +++ b/packages/forge2d/example/web/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/forge2d/example/web/main.dart b/packages/forge2d/example/web/main.dart new file mode 100644 index 00000000..ab4a5468 --- /dev/null +++ b/packages/forge2d/example/web/main.dart @@ -0,0 +1,268 @@ +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(); +} + +/// The DOM events the app listens to. +enum DomEvent { + keyDown, + keyUp, + pointerDown, + pointerMove, + pointerUp, + pointerCancel, + click; + + /// The DOM event type name. + String get type => name.toLowerCase(); +} + +void _listen( + DomEvent event, + EventTarget target, + void Function(T) handler, +) => target.addEventListener( + event.type, + ((Event domEvent) => handler(domEvent 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(); + Scene _currentScene = scenes.first; + 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( + DomEvent.keyDown, + document, + (event) => _hooks.onKey?.call(event.key, down: true), + ); + _listen( + DomEvent.keyUp, + document, + (event) => _hooks.onKey?.call(event.key, down: false), + ); + _listen( + DomEvent.click, + document.getElementById('reset')!, + (_) => _select(_currentScene), + ); + _listen(DomEvent.pointerDown, _canvas, _onPointerDown); + _listen(DomEvent.pointerMove, _canvas, _onPointerMove); + _listen( + DomEvent.pointerUp, + _canvas, + (_) => _releaseMouseJoint(), + ); + _listen( + DomEvent.pointerCancel, + _canvas, + (_) => _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(); + _currentScene = scene; + _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()); + _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(); + 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..0536fe6f --- /dev/null +++ b/packages/forge2d/example/web/scenes.dart @@ -0,0 +1,490 @@ +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( + // The renderer reads the color from the shape's user data. + userData: color, + material: SurfaceMaterial( + friction: friction ?? 0.6, + restitution: restitution ?? 0, + customColor: color, + ), +); + +ShapeDef _static() => ShapeDef( + userData: Palette.slate, + 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: 46, + centerY: 17, + build: (world, hooks) { + 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; + + 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( + userData: Palette.dynamics[tint % Palette.dynamics.length], + density: dominoDensity, + material: SurfaceMaterial( + friction: 0.1, + restitution: 0.65, + customColor: Palette.dynamic(tint++), + ), + ), + ); + } + + 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( + userData: Palette.rose, + density: 10, + 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), + ], + ), + ); + + // 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), + 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), + enableSleep: false, + ), + )..createShape( + Circle(radius: 0.45), + ShapeDef( + userData: Palette.rose, + 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());