35 Commits

Author SHA1 Message Date
03c7d44aad Release v0.8.0
Some checks failed
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.88.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
- Replaced "bevy_spatial" with "avian2d"
- Upgraded Bevy to 0.17

The bevy_spatial crate is still stuck on Bevy 0.16 and I'd like to move
forward and start playing with some of the new UI stuff. I've replaced
it with Avian physics, although I'm only using the spatial querying
facilities not the rest of the physics engine.
2025-12-25 23:47:14 -06:00
975f2a0b92 Install libwayland-dev in CI workflow
Some checks failed
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.88.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
Bevy 0.17 enables Wayland by default so the CI workflow needs to have it
installed.
2025-12-25 23:27:33 -06:00
3a3b8181f9 Upgrade to Bevy 0.17
Avian physics has been updated for Bevy 0.17 while bevy_spatial has not.
Now that the program is switched over, I can finally pull in the new
versions of everything.
2025-12-25 23:17:14 -06:00
62feb6f313 Fix: separation() system needs smaller view range
I figured out why the new flocking behavior is different...

The previous version used 1/4th the view range for boid separation and I
didn't preserve that.
2025-12-25 22:58:03 -06:00
9797b90415 Remove dependency on crate "bevy_spatial" 2025-12-24 16:04:25 -06:00
1846b7065e Convert flocking systems to use Avian2d 2025-12-24 16:02:46 -06:00
e252e3385c (autoformat) 2025-12-24 15:44:54 -06:00
5f0b428811 Debug scanner uses shape_intersections
That was really easy. I feel dumb for making a checkpoint commit before.
Oh well, onwards to the next step: Swap over the flocking systems.
2025-12-24 15:01:03 -06:00
119d7acf09 WIP: Debug tool scans using Avian2d shape casting
Working demonstration, but I'm not sure shape casting is the action I
want to be doing. I've just found a "shape_intersections" method in the
docs, but I'm saving my progress before making further changes.
2025-12-24 14:54:49 -06:00
63f15ae6a7 Update MSRV to 1.88.0
One or more of the transitive dependencies needs Rust 1.88 or newer, so
this becomes our minimum version. It may be possible to build with an
older Rust by manually picking dependency versions but I'm not going to
do that.

Ideally, `-Zminimal-versions` would pick out those versions, but lots of
crates don't correctly specify minimum depdendency versions. As a
result, our transitive dependencies resolve to
matching-but-non-functional versions.
2025-12-24 13:05:05 -06:00
ba01d8137f Mark v0.7.0
Mark new version, update the lockfile, and autoformat the project.
2025-12-22 11:07:56 -06:00
b8c28529e6 Remove the Player Boid
The player-controlled boid was for testing the flocking behavior. It has
outlived this purpose and I never even bothered to make it larger like
the others. It's finally going away completely.
2025-12-22 10:56:47 -06:00
fd161dc26b Wire in the magic physics vars as a Resource
I don't know what to call these, so they're "misc. parameters" for now.
2025-12-22 10:45:02 -06:00
c2cf100c05 Flocking params as a resource & egui-inspector
The birdoid flocking parameters are now a resource named
`FlockingParameters`. Adjustments can be made using the Egui inspector
widget, although I plan to make a custom UI in the future.
2025-12-21 16:33:04 -06:00
0828518963 Begin 0.7.0-dev, small upgrade to behavior params
I'm not happy with the flocking behavior so I fiddled with the program
to find these new behavior parameters. It's annoying to constantly
rebuild the program, though, and I'd like for the end-user to be able to
fiddle with it at runtime. To that end, v0.7.0 shall be the UI update.
2025-12-21 15:31:08 -06:00
5ef91ec88c Remove unused print_gizmo_config() function 2025-12-20 09:42:14 -06:00
8346527d05 Release v0.6.0
All checks were successful
check / stable / fmt (push) Successful in 20s
check / nightly / doc (push) Successful in 2m21s
check / ubuntu / stable / features (push) Successful in 2m25s
check / ubuntu / 1.87.0 (push) Successful in 2m19s
2025-11-15 15:15:01 -06:00
6329d000f5 Use compressed WASM for even smaller webroot size
All checks were successful
check / stable / fmt (push) Successful in 21s
check / nightly / doc (push) Successful in 2m24s
check / ubuntu / stable / features (push) Successful in 2m27s
check / ubuntu / 1.87.0 (push) Successful in 2m18s
2025-11-15 15:14:20 -06:00
46892ec32c Fix: Syntax errors in README.md
Some checks failed
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.87.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
2025-11-08 11:28:43 -06:00
f6c6f26b8b Update CI, the MSRV is 1.87
Some checks failed
check / stable / fmt (push) Successful in 21s
check / nightly / doc (push) Successful in 2m19s
check / ubuntu / stable / features (push) Successful in 2m26s
check / ubuntu / 1.87.0 (push) Has been cancelled
I really should have fixed this forever ago and just didn't. Good thing
nobody uses this program and it doesn't matter :p
2025-11-08 11:19:15 -06:00
9f8598c794 Drop CI semver check, this isn't a library
Semver checks only make sense for things that have a public interface.
While an application technically does, these semver checks are for the
Rust API not the GUI shape or something. It's for checking that a
library upholds it's semver promise.

This is not a library, so these checks don't make sense.
2025-11-08 11:14:27 -06:00
b64771f3fc Drop CI cargo-clippy check, can't run on Gitea
The Reviewdog tool hits GitHub API endpoints that don't have an
equivalent on Gitea.
2025-11-08 11:11:15 -06:00
d1941217c4 Release v0.5.0
Some checks failed
check / stable / fmt (push) Successful in 21s
check / beta / clippy (push) Failing after 1m15s
check / semver (push) Has been cancelled
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.79.0 (push) Has been cancelled
check / stable / clippy (push) Has been cancelled
2025-11-08 11:07:04 -06:00
ba5183b30d Remove bogus dependencies from build container
These are only required for Linux builds, not WASM/WASI builds.
2025-11-08 10:59:30 -06:00
3e3bbd973f Update Dockerfile to use the new Make-based build
I'm going to run into consistency problems at some point, so I'm going
to get ahead of that by updating the container build process.
2025-11-08 10:58:48 -06:00
5abe894354 Add a bunch of CSS
Some checks failed
check / beta / clippy (push) Has been cancelled
check / stable / clippy (push) Has been cancelled
check / semver (push) Has been cancelled
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.79.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
It's not amazing work, but now there's *something.*
2025-11-08 10:37:34 -06:00
86483497e9 Add an install target 2025-11-06 14:18:18 -06:00
3f6a4ae532 Set charset & viewport meta tags 2025-11-06 13:24:11 -06:00
7a5148dc74 Update the README with new web build variant info 2025-11-06 12:19:26 -06:00
8d4b033922 Add the tarball-standalone recipe(s)
Some checks failed
check / stable / fmt (push) Successful in 21s
check / beta / clippy (push) Failing after 54s
check / stable / clippy (push) Failing after 49s
check / semver (push) Failing after 5m25s
check / nightly / doc (push) Successful in 2m22s
check / ubuntu / stable / features (push) Successful in 2m32s
check / ubuntu / 1.79.0 (push) Failing after 26s
I forgot to put this in when I added the phony target. Oops.
2025-11-06 10:46:13 -06:00
0c915b025e HTML-installing targets remove the other one
This is actually a build configuration task, but I don't have a tool to
do that (no `./configure.sh` to run!).

To work around this, I'll just have each of the html-installing targets
install their own and remove the other. That way back-to-back builds
don't accidentally contaminate each other. The WASM and JS files are
identical, so they don't need this treatment.
2025-11-06 10:22:44 -06:00
15a1d5c6de New build variants: "standalone" and "bundle-able"
The `web-standalone` target does what the old `web` target did: Produce
the WASM, JS, and an index.html so the output can be served up directly.

The new `web` target renames the "index.html" to "boids.html" so it can
be used as a submodule/subpage in a larger website build.
2025-11-06 10:20:30 -06:00
e7e5337f22 Place Makefile 'configurables' up top
Variables that a package consumer might want to adjust should be placed
at the top of the file so they are immediately visible. Any constants
shall live below those (just the SRC folder, really).
2025-11-06 09:54:30 -06:00
e65deec5ea Rename the Makefile, add usage note at the top
I had named it makefile_web" in an effort to communicate to the user
that the Make-based build path is only for web builds. It's annoying to
type all that out, though, and it doesn't seem like other projects
follow this convention. I'll just put a usage note at the top. It's not
like the Makefile can be mis-used to make a non-web version. There is no
footgun here.
2025-11-06 09:43:11 -06:00
29d0b644ec Remove fly.toml from repo
Some checks failed
check / stable / fmt (push) Successful in 51s
check / beta / clippy (push) Failing after 1m18s
check / stable / clippy (push) Failing after 1m0s
check / semver (push) Failing after 5m49s
check / nightly / doc (push) Successful in 2m20s
check / ubuntu / stable / features (push) Successful in 2m26s
check / ubuntu / 1.79.0 (push) Failing after 28s
As a general rule, this probably shouldn't be in the main repo. I've
also deleted the Fly app, so it doesn't need to exist anywhere.
2025-11-04 14:03:55 -06:00
12 changed files with 2576 additions and 903 deletions

View File

@@ -34,44 +34,6 @@ jobs:
components: rustfmt
- name: cargo fmt --check
run: cargo fmt --check
clippy:
runs-on: ubuntu-latest
name: ${{ matrix.toolchain }} / clippy
permissions:
contents: read
checks: write
strategy:
fail-fast: false
matrix:
# Get early warning of new lints which are regularly introduced in beta channels.
toolchain: [stable, beta]
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
components: clippy
- name: cargo clippy
uses: giraffate/clippy-action@v1
with:
reporter: 'github-pr-check'
github_token: ${{ secrets.GITHUB_TOKEN }}
semver:
runs-on: ubuntu-latest
name: semver
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: cargo-semver-checks
uses: obi1kenobi/cargo-semver-checks-action@v2
doc:
# run docs generation on nightly rather than stable. This enables features like
# https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an
@@ -83,7 +45,7 @@ jobs:
with:
submodules: true
- name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Install nightly
uses: dtolnay/rust-toolchain@nightly
- name: cargo doc
@@ -100,7 +62,7 @@ jobs:
with:
submodules: true
- name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev cmake
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev cmake
- name: Install stable
uses: dtolnay/rust-toolchain@stable
- name: cargo install cargo-hack
@@ -116,14 +78,14 @@ jobs:
# https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
strategy:
matrix:
msrv: ["1.79.0"]
msrv: ["1.88.0"]
name: ubuntu / ${{ matrix.msrv }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Install ${{ matrix.msrv }}
uses: dtolnay/rust-toolchain@master
with:

2797
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
[package]
name = "another-boids-in-rust"
version = "0.4.0"
version = "0.8.0"
edition = "2024"
license = "AGPL-3.0-only"
rust-version = "1.88.0"
[dependencies]
bevy = "0.16.0"
avian2d = "0.4"
bevy = "0.17.0"
bevy-inspector-egui = "0.34"
# Grand-dependency pins
# ab_glyph = "0.2.16"
@@ -17,16 +20,6 @@ bevy = "0.16.0"
# nonmax = "0.5.1"
# rand = "0.8.0"
# Use regular bevy_spatial on non-WASM builds
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
bevy_spatial = "0.11.0"
# Use bevy_spatial *without* the kdtree_rayon feature when building for WASM.
[target.'cfg(target_arch = "wasm32")'.dependencies.bevy_spatial]
version = "0.11.0"
default-features = false
features = ["kdtree"]
[profile.dev]
opt-level = 1

View File

@@ -1,19 +1,12 @@
FROM rust:1.89 AS builder
RUN apt-get update
RUN apt-get install -y --no-install-recommends libasound2-dev libudev-dev
RUN rustup target add wasm32-unknown-unknown
RUN cargo install --locked wasm-bindgen-cli
# Copy only the package manifest and source code. Otherwise changes to anything
# will cause Docker to re-run `cargo build` even when the source hasn't changed.
COPY src/ ./src
COPY Cargo.toml ./Cargo.toml
COPY Cargo.lock ./Cargo.lock
COPY . .
RUN cargo build --locked --target wasm32-unknown-unknown --profile=wasm-release
RUN wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name "boids" target/wasm32-unknown-unknown/wasm-release/another-boids-in-rust.wasm
COPY www/index.html out/index.html
RUN make -j
FROM busybox:musl
RUN mkdir -p /var/www

73
Makefile Normal file
View File

@@ -0,0 +1,73 @@
# This script produces a web build. If you aren't trying to do that, it is
# entirely useless to you.
# Patch these to select a different build profile or target
# The target shouldn't change any time soon. WASM64, I guess. Other targets
# aren't aimed at the web, so you shouldn't be using this makefile.
CARGO_TARGET := wasm32-unknown-unknown
CARGO_PROFILE := wasm-release
# Override DESTDIR to set a custom install path (such as your web root)
DESTDIR ?= .
SRC_DIR = ./src
SRCS := $(wildcard $(SRC_DIR)/**)
.PHONY: clean full-clean install tarball tarball-standalone web web-standalone
# "Standalone" version
# (i.e., it includes an index.html so it can be placed on a server as-is)
web-standalone: out/boids.js out/boids_bg.wasm.gz out/index.html
# "Bundle-able" version. The host site must provide it's own HTML page.
web: out/boids.js out/boids_bg.wasm.gz out/boids.html
tarball: boids_web_root.tar
tarball_standalone: boids_web_root_standalone.tar
boids_web_root.tar: out/boids.js out/boids_bg.wasm.gz out/boids.html
tar -caf $@ $^
boids_web_root_standalone.tar: out/boids.js out/boids_bg.wasm.gz out/index.html
tar -caf $@ $^
target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm: $(SRCS) Cargo.lock Cargo.toml
cargo build --profile $(CARGO_PROFILE) --target $(CARGO_TARGET)
out:
mkdir $@
# Both the JS and WASM files are generated by the wasm-bindgen call, so both
# get to be on the target half of this recipe.
out/boids.js out/boids_bg.wasm.gz &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm | out
wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name boids target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm
gzip -9 -f out/boids_bg.wasm
# Copies the index page to the output
out/index.html: www/index.html
cp -a $< $@
rm -f out/boids.html
# Like `out/index.html`, but renames it for use in a larger site.
out/boids.html: www/index.html
cp -a $< $@
rm -f out/index.html
# Clean the web build, but not the Cargo cache. Cargo handles it's own caching
# and I don't want to obliterate it all the time.
clean:
rm -rf out/ boids_web_root.tar boids_web_root_standalone.tar
# Delete everything, including the Cargo build cache. In case someone needs
# this, I guess.
full-clean: clean
cargo clean
# Installation goal. It's meant to be a helper utility for moving the built
# output into the web root. Only supports the "bundle-able" mode.
install: web
install -dm0755 $(DESTDIR)
install -m0644 out/boids.js $(DESTDIR)/
install -m0644 out/boids_bg.wasm.gz $(DESTDIR)/
install -m0644 out/boids.html $(DESTDIR)/

View File

@@ -21,13 +21,42 @@ You'll need a working Rust toolchain, of course. See the [rustup](https://rustup
### Web
This project creates a "static site," meaning a complete deployment of the site is simply copying the output folder onto a webserver.
This project creates a "static site," meaning a complete deployment of the site is simply copying the output folder onto a webserver. There are **two** ways to use this.
1. Build `make -f ./makefile_web web`
1. A standalone application which can be quickly hosted as-is. This includes the WASM, it's JS glue, and an index.html page.
2. A sub-page in a larger website. This is actually the same, but names it's HTML page "boids.html" so consumers (you) can provide their own index.html.
- I'm not using a JS Bundler at this time. If you're familiar with JS development, this probably looks like a dumb way to do it. Sorry about that.
#### Standalone build:
1. Build `make web-standalone`
2. Serve `python3 -m http.server -d ./out`
3. Visit site in browser: `http://localhost:8000`
You can use any HTTP server you like. Here, I'm using the Python3 built-in [http.server module](https://docs.python.org/3/library/http.server.html); which is **NOT** recommended for production use. Don't put that on the Internet! Alternatives include [Miniserve](https://crates.io/crates/miniserve) and [BusyBox](https://busybox.net/). The latter of which I'm using in the Docker image.
To quickly get a tarball, use `make tarball_standalone`. If you're trying to build and upload the program somewhere, this may provide a bit of convenience. Compressing it may be a good idea, too.
#### "Bundle-able" build
For a "bundle-able" build, you'll need to write your own index.html and link to the boids.html file.
Basically, just throw in a hyperlink with `<a href="boids.html">Boids</a>`
```html
<!DOCTYPE html>
<html>
<!-- your page content, etc -->
<body>
<a href="boids.html">Boids</a>
</body>
<!-- more page content, etc -->
</html>
```
As mentioned in the option 2 description, I'm not using a JS Bundler. There is no "package.json" or anything to integrate properly with a JS framework. I plan to fix that at some point, but for now there are just a bunch of files to grab.
---
You can use any HTTP server you like. In the steps above, I'm using the Python3 built-in [http.server module](https://docs.python.org/3/library/http.server.html); which is **NOT** recommended for production use. Don't put that on the Internet! Alternatives include [Miniserve](https://crates.io/crates/miniserve) and [BusyBox](https://busybox.net/). The latter of which I'm using in the Docker image.
### Web, but as a Docker Container
@@ -45,6 +74,7 @@ You may also notice that the Dockerfile doesn't call on the Makefile. This is be
## Controls
| Input | Effect |
|-|-|
| Mouse | The scanner circle is attached to the mouse cursor. Move it to scan boids within the radius. |
| Left mouse button | Put scanner into center-of-mass mode |
| Right mouse button | Put scanner into average velocity mode |

View File

@@ -1,25 +0,0 @@
# fly.toml app configuration file generated for boids-autumn-lake-5810 on 2025-09-03T16:02:00-05:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'boids-autumn-lake-5810'
primary_region = 'ord'
[build]
[env]
PORT = '8080'
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '256mb'
cpu_kind = 'shared'
cpus = 1

View File

@@ -1,41 +0,0 @@
SRC_DIR = ./src
SRCS := $(wildcard $(SRC_DIR)/**)
# Patch these to select a different build profile or target
# The target shouldn't change any time soon. WASM64, I guess. Other targets
# aren't aimed at the web, so you shouldn't be using this makefile.
CARGO_TARGET := wasm32-unknown-unknown
CARGO_PROFILE := wasm-release
.PHONY: clean full-clean web tarball
web: out/boids.js out/boids_bg.wasm out/index.html
tarball: boids_web_root.tar
boids_web_root.tar: out/boids.js out/boids_bg.wasm out/index.html
tar -caf $@ $^
target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm: $(SRCS) Cargo.lock Cargo.toml
cargo build --profile $(CARGO_PROFILE) --target $(CARGO_TARGET)
out:
mkdir $@
# Both the JS and WASM files are generated by the wasm-bindgen call, so both
# get to be on the target half of this recipe.
out/boids.js out/boids_bg.wasm &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm | out
wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name boids target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm
out/index.html: www/index.html
cp -a $< $@
# Clean the web build, but not the Cargo cache. Cargo handles it's own caching
# and I don't want to obliterate it all the time.
clean:
rm -rf out/ boids_web_root.tar
# Delete everything, including the Cargo build cache. In case someone needs
# this, I guess.
full-clean: clean
cargo clean

View File

@@ -1,62 +1,81 @@
pub mod physics;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_spatial::{
AutomaticUpdate, SpatialAccess, SpatialStructure, TransformMode, kdtree::KDTree2,
};
use crate::birdoids::physics::{Force, Velocity, apply_velocity};
use bevy_inspector_egui::{InspectorOptions, prelude::ReflectInspectorOptions};
const BACKGROUND_COLOR: Color = Color::srgb(0.4, 0.4, 0.4);
const PLAYERBOID_COLOR: Color = Color::srgb(1.0, 0.0, 0.0);
const TURN_FACTOR: f32 = 1.0;
const BOID_VIEW_RANGE: f32 = 15.0;
const COHESION_FACTOR: f32 = 1.0;
const SEPARATION_FACTOR: f32 = 10.0;
const ALIGNMENT_FACTOR: f32 = 1.0;
const SPACEBRAKES_COEFFICIENT: f32 = 0.5;
const LOW_SPEED_THRESHOLD: f32 = 50.0;
const HIGH_SPEED_THRESHOLD: f32 = 200.0;
pub struct BoidsPlugin;
impl Plugin for BoidsPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(
AutomaticUpdate::<TrackedByKdTree>::new()
// .with_frequency(Duration::from_secs_f32(0.3))
.with_transform(TransformMode::GlobalTransform)
.with_spatial_ds(SpatialStructure::KDTree2),
)
.insert_resource(ClearColor(BACKGROUND_COLOR))
.add_systems(Startup, (spawn_camera, spawn_boids))
.add_systems(
FixedUpdate,
(
apply_velocity,
turn_if_edge,
check_keyboard,
cohesion,
separation,
alignment,
speed_controller,
),
);
app.insert_resource(ClearColor(BACKGROUND_COLOR))
.insert_resource(FlockingParameters::new())
.register_type::<FlockingParameters>()
.insert_resource(MiscParams::new())
.register_type::<MiscParams>()
.add_systems(Startup, (spawn_camera, spawn_boids))
.add_systems(
FixedUpdate,
(
apply_velocity,
turn_if_edge,
cohesion,
separation,
alignment,
speed_controller,
),
);
}
}
#[derive(InspectorOptions, Reflect, Resource, Debug, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub(crate) struct FlockingParameters {
view_range: f32,
cohesion: f32,
separation: f32,
alignment: f32,
}
impl FlockingParameters {
pub(crate) fn new() -> Self {
Self {
view_range: 15.0,
cohesion: 1.0,
separation: 5.0,
alignment: 2.0,
}
}
}
#[derive(InspectorOptions, Reflect, Resource, Debug, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub(crate) struct MiscParams {
turn_factor: f32,
spacebrakes: f32,
minimum_speed: f32,
maximum_speed: f32,
}
impl MiscParams {
pub(crate) fn new() -> Self {
Self {
turn_factor: 1.0,
spacebrakes: 0.5,
minimum_speed: 50.0,
maximum_speed: 200.0,
}
}
}
#[derive(Component)]
#[require(Velocity, Force, TrackedByKdTree)]
#[require(Velocity, Force)]
pub(crate) struct Boid;
// It's a Boid, but with an extra component so the player
// can control it from the keyboard
#[derive(Component)]
struct PlayerBoid;
#[derive(Component, Default)]
pub struct TrackedByKdTree;
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
@@ -76,26 +95,21 @@ fn spawn_boids(
Mesh2d(meshes.add(Circle::new(1.0))),
MeshMaterial2d(materials.add(Color::srgb(1.0, 1.0, 1.0))),
Transform::from_translation(vel * 20.0),
// RigidBody::Dynamic,
Collider::circle(1.0),
));
}
commands.spawn((
Boid,
PlayerBoid,
Mesh2d(meshes.add(Triangle2d::default())),
MeshMaterial2d(materials.add(PLAYERBOID_COLOR)),
));
}
/// Controls the boid's minimum and maximum speed according to a low- and
/// high-threshold. Boids moving too slow are sped up, and boids moving too
/// fast are slowed down.
fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>) {
fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>, params: Res<MiscParams>) {
for (vel, mut impulse) in &mut mobs {
if vel.0.length() < LOW_SPEED_THRESHOLD {
impulse.0 += vel.0 * SPACEBRAKES_COEFFICIENT;
} else if vel.0.length() > HIGH_SPEED_THRESHOLD {
impulse.0 += -vel.0 * SPACEBRAKES_COEFFICIENT;
if vel.0.length() < params.minimum_speed {
impulse.0 += vel.0 * params.spacebrakes;
} else if vel.0.length() > params.maximum_speed {
impulse.0 += -vel.0 * params.spacebrakes;
}
}
}
@@ -103,21 +117,22 @@ fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>) {
fn turn_if_edge(
mut query: Query<(&mut Transform, &mut Velocity), With<Boid>>,
window: Query<&Window>,
params: Res<MiscParams>,
) {
if let Ok(window) = window.single() {
let (width, height) = (window.resolution.width(), window.resolution.height());
for (transform, mut velocity) in &mut query {
let boid_pos = transform.translation.xy();
if boid_pos.x <= -width / 2. + 50. {
velocity.x += TURN_FACTOR;
velocity.x += params.turn_factor;
} else if boid_pos.x >= width / 2. - 50. {
velocity.x -= TURN_FACTOR;
velocity.x -= params.turn_factor;
}
if boid_pos.y <= -height / 2. + 50. {
velocity.y += TURN_FACTOR;
velocity.y += params.turn_factor;
} else if boid_pos.y >= height / 2. - 50. {
velocity.y -= TURN_FACTOR;
velocity.y -= params.turn_factor;
}
}
} else {
@@ -125,38 +140,12 @@ fn turn_if_edge(
}
}
fn check_keyboard(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut app_exit_events: ResMut<Events<bevy::app::AppExit>>,
mut query: Query<&mut Force, With<PlayerBoid>>,
) {
if keyboard_input.just_pressed(KeyCode::KeyQ) {
app_exit_events.send(bevy::app::AppExit::Success);
}
let mut impulse = query
.single_mut()
.expect("[birdoids_plugin::check_keyboard()] ->> There seems to be more than one player... How did that happen?");
let mut dir = Vec2::ZERO;
if keyboard_input.pressed(KeyCode::ArrowLeft) {
dir.x -= 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowRight) {
dir.x += 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowDown) {
dir.y -= 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowUp) {
dir.y += 1.0;
}
**impulse += dir.extend(0.0) * 50.0;
}
fn cohesion(
spatial_tree: Res<KDTree2<TrackedByKdTree>>,
spatial: SpatialQuery,
// TODO: Ensure this is logically sound. I think it will fail the "disjoint queries" requirement.
boid_locations: Query<&Transform, With<Boid>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) {
// for each boid
// find neighbors
@@ -164,14 +153,23 @@ fn cohesion(
// find vector from boid to flock CoM
// apply force
for (this_entt, transform, mut force) in &mut boids {
let (len, sum) = spatial_tree
.within_distance(transform.translation.xy(), BOID_VIEW_RANGE)
let (len, sum) = spatial
.shape_intersections(
&Collider::circle(props.view_range),
transform.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.iter()
.filter_map(|(pos, entt)| {
.filter_map(|&entt| {
// extract neighbor's position
// Skip self-comparison. A boid should not try to separate from itself.
let entt = entt
.expect("within_distance gave me an entity... with no entity ID... somehow");
if this_entt == entt { None } else { Some(pos) }
if this_entt == entt {
None
} else {
let tsfm = boid_locations.get(entt).unwrap();
Some(tsfm.translation.xy())
}
})
.enumerate()
.fold((0, Vec2::ZERO), |(_len, com), (idx, pos)| (idx, com + pos));
@@ -183,47 +181,56 @@ fn cohesion(
continue;
};
let impulse = cohesive_force(center_of_mass, transform.translation.xy()).expect("damn");
let impulse = cohesive_force(center_of_mass, transform.translation.xy(), props.view_range)
.expect("damn");
force.0 -= impulse.0 * COHESION_FACTOR;
force.0 -= impulse.0 * props.cohesion;
}
}
fn separation(
spatial_tree: Res<KDTree2<TrackedByKdTree>>,
spatial: SpatialQuery,
boid_locations: Query<&Transform, With<Boid>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) {
// for each boid
// find neighbors
// sum force from neighbors
// apply force
for (this_entt, tsfm, mut force) in &mut boids {
let impulse = spatial_tree
.within_distance(tsfm.translation.xy(), BOID_VIEW_RANGE / 4.0)
let impulse = spatial
.shape_intersections(
&Collider::circle(props.view_range / 4.0),
tsfm.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.iter()
.filter_map(|(pos, entt)| {
.filter_map(|&entt| {
// Skip self-comparison. A boid should not try to separate from itself.
let entt = entt
.expect("within_distance gave me an entity... with no entity ID... somehow");
if this_entt == entt {
None
} else {
let pos = boid_locations.get(entt).unwrap().translation.xy();
Some(pos.extend(0.0))
}
})
.fold(Vec3::ZERO, |acc, other| {
// let force = tsfm.translation - other;
let force = separation_force(tsfm.translation.xy(), other.xy()).expect("angy");
let force = separation_force(tsfm.translation.xy(), other.xy(), props.view_range)
.expect("angy");
acc + force.0
});
force.0 += impulse * SEPARATION_FACTOR;
force.0 += impulse * props.separation;
}
}
fn alignment(
spatial_tree: Res<KDTree2<TrackedByKdTree>>,
spatial: SpatialQuery,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
boid_velocities: Query<&Velocity, With<Boid>>,
props: Res<FlockingParameters>,
) {
// for each boid
// find neighbors
@@ -233,14 +240,16 @@ fn alignment(
// apply steering force
for (this_entt, transform, mut force) in &mut boids {
let neighbors = spatial_tree.within_distance(transform.translation.xy(), BOID_VIEW_RANGE);
let neighbors = spatial.shape_intersections(
&Collider::circle(props.view_range),
transform.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
);
// averaging divides by length. Guard against an empty set of neighbors
let (len, sum) = neighbors
.iter()
// Extract the velocities by `get()`ing from another query param.
.filter_map(|(_pos, maybe_entt)| {
let entt = maybe_entt
.expect("Neighbor boid has no Entity ID. I don't know what this means");
.filter_map(|&entt| {
if this_entt == entt {
None
} else {
@@ -261,7 +270,7 @@ fn alignment(
};
let boid_vel = boid_velocities.get(this_entt).unwrap();
force.0 += (avg.extend(0.0) - boid_vel.0) * ALIGNMENT_FACTOR;
force.0 += (avg.extend(0.0) - boid_vel.0) * props.alignment;
}
}
@@ -287,13 +296,13 @@ fn average_of_vec2s(points: impl Iterator<Item = Vec2>) -> Option<Vec2> {
}
// f(x) = 4((x-0.5)^3 + 0.125)
fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> {
fn cohesive_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
let deviation = target - boid;
/*
Scale deviation vector by the boid's view range. The curve is made to
operate on the range (0, 1), so that needs to be the viewing circle.
*/
let scaled = deviation / BOID_VIEW_RANGE;
let scaled = deviation / view_range;
let mag = scaled.length();
if mag > 0.0 {
let cube: f32 = (mag - 0.5).powf(3.0);
@@ -310,9 +319,9 @@ fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> {
}
// f(x) = x^2 - 1
fn separation_force(boid: Vec2, target: Vec2) -> Option<Force> {
fn separation_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
// Scale from BOID_VIEW_RANGE to unit space
let distance_unit = (target - boid) / BOID_VIEW_RANGE;
let distance_unit = (target - boid) / view_range;
let mag = distance_unit.length();
if mag > 0.0 {
let force_mag = mag.powf(2.0) - 1.0;
@@ -329,14 +338,15 @@ mod tests {
use crate::birdoids::{cohesive_force, separation_force};
use super::{BOID_VIEW_RANGE, physics::Force};
use super::{FlockingParameters, physics::Force};
// forces are relative to the boid's view range, so all
// distances need to be fractions of that
#[test]
fn check_cohesion_zero_zero() {
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO);
let props = FlockingParameters::new();
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none());
}
@@ -347,36 +357,56 @@ mod tests {
#[test]
fn check_cohesion_midpoint_x_positive() {
// Pull right 0.5 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.5 * props.view_range, 0.0),
props.view_range
)
);
}
#[test]
fn check_cohesion_midpoint_x_negative() {
// Pull left 0.5 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(-0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-0.5 * props.view_range, 0.0),
props.view_range
)
);
}
#[test]
fn check_cohesion_edge_x_positive() {
// pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(1.0 * props.view_range, 0.0),
props.view_range
)
);
}
#[test]
fn check_cohesion_edge_x_negative() {
// pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(-1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-1.0 * props.view_range, 0.0),
props.view_range
)
);
}
@@ -387,43 +417,64 @@ mod tests {
#[test]
fn check_cohesion_midpoint_y_positive() {
// Pull up 0.5 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, 0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 0.5 * props.view_range),
props.view_range
)
);
}
#[test]
fn check_cohesion_midpoint_y_negative() {
// Pull down 0.5 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, -0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -0.5 * props.view_range),
props.view_range
)
);
}
#[test]
fn check_cohesion_edge_y_positive() {
// Pull up 1.0 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, 1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE))
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 1.0 * props.view_range),
props.view_range
)
);
}
#[test]
fn check_cohesion_edge_y_negative() {
// pull down 0.2 units
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, -1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE),)
cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -1.0 * props.view_range),
props.view_range
)
);
}
// Separation 0,0 test
#[test]
fn check_separation_zero_zero() {
let force = separation_force(Vec2::ZERO, Vec2::ZERO);
let props = FlockingParameters::new();
let force = separation_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none());
}
@@ -432,39 +483,53 @@ mod tests {
// *********************
#[test]
fn check_separation_midpoint_x_positive() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.75, 0.0, 0.0))), // expected force
separation_force(
Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0), // boid position
Vec2::ZERO, // obstacle position
Vec2::new(0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position
props.view_range
)
);
}
#[test]
fn check_separation_midpoint_x_negative() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(-0.75, 0.0, 0.0))), // expected force
separation_force(
Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0), // boid position
Vec2::ZERO, // obstacle position
Vec2::new(-0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position
props.view_range
)
);
}
#[test]
fn check_separation_edge_x_positive() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,),
separation_force(
Vec2::new(1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
);
}
#[test]
fn check_separation_edge_x_negative() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,),
separation_force(
Vec2::new(-1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
);
}
@@ -473,33 +538,53 @@ mod tests {
// *********************
#[test]
fn check_separation_midpoint_y_positive() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, 0.75, 0.0))),
separation_force(Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE), Vec2::ZERO,)
separation_force(
Vec2::new(0.0, 0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
);
}
#[test]
fn check_separation_midpoint_y_negative() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::new(0.0, -0.75, 0.0))),
separation_force(Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE), Vec2::ZERO,)
separation_force(
Vec2::new(0.0, -0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
);
}
#[test]
fn check_separation_edge_y_positive() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE), Vec2::ZERO,)
separation_force(
Vec2::new(0.0, 1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
)
}
#[test]
fn check_separation_edge_y_negative() {
let props = FlockingParameters::new();
assert_eq!(
Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE), Vec2::ZERO,)
separation_force(
Vec2::new(0.0, -1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
)
}
}

View File

@@ -1,8 +1,8 @@
use avian2d::prelude::*;
use bevy::{prelude::*, window::PrimaryWindow};
use bevy_spatial::{SpatialAccess, kdtree::KDTree2};
use crate::birdoids::{
Boid, TrackedByKdTree, center_of_boids, physics::Force, physics::Velocity, velocity_of_boids,
Boid, center_of_boids, physics::Force, physics::Velocity, velocity_of_boids,
};
const SCANRADIUS: f32 = 50.0;
@@ -106,15 +106,10 @@ fn update_scanner_mode(
}
}
fn print_gizmo_config(query: Query<(&SelectionMode, &ScannerMode), With<Cursor>>) {
let (select, scan) = query.single().unwrap();
println!("Selection: {select:?}, Scanning: {scan:?}");
}
fn do_scan(
boids_query: Query<(&Transform, &Velocity, &Force), With<Boid>>,
scanner_query: Query<(&Transform, &SelectionMode, &ScannerMode), With<Cursor>>,
spatial_tree: Res<KDTree2<TrackedByKdTree>>,
spatial: SpatialQuery,
/* Push info to summary somewhere */
mut gizmos: Gizmos,
) {
@@ -122,25 +117,35 @@ fn do_scan(
match select_mode {
SelectionMode::NearestSingle => todo!(),
SelectionMode::CircularArea => {
let boids = spatial_tree.within_distance(cursor_pos.translation.xy(), SCANRADIUS);
let boids = spatial
.shape_intersections(
&Collider::circle(SCANRADIUS),
cursor_pos.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.into_iter()
.map(|entt| {
let (tsfm, vel, _) = boids_query
.get(entt)
.expect("Debugger scanned a Boid missing one of it's components!");
(tsfm, vel)
});
match scan_mode {
ScannerMode::CenterOfMass => {
if let Some(center_mass) = center_of_boids(
// boids returns too many things.
// Map over it and extract only the Vec3's
boids.iter().map(|item| item.0),
// `center_of_boids` needs an Iterator<Item = Vec2>, so we map over
// the output tuple to make one.
boids.map(|item| item.0.translation.xy()),
) {
gizmos.circle_2d(center_mass, 1.0, bevy::color::palettes::css::RED);
}
}
ScannerMode::Velocity => {
if let Some(avg_velocity) = velocity_of_boids(boids.iter().map(|item| {
let entity_id = item.1.unwrap_or_else(|| panic!("Entity has no ID!"));
let (_, vel, _) = boids_query
.get(entity_id)
.unwrap_or_else(|_| panic!("Boid has no Velocity component!"));
(*vel).xy() * 1.0
})) {
if let Some(avg_velocity) =
velocity_of_boids(boids.map(|item| (*item.1).xy() * 1.0))
{
// cursor_pos.translation is already in world space, so I can skip the window -> world transform like in update_cursor()
gizmos.line_2d(
cursor_pos.translation.xy(),

View File

@@ -1,11 +1,15 @@
use avian2d::prelude::*;
use bevy::prelude::*;
mod birdoids;
mod debug_plugin;
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::ResourceInspectorPlugin};
use birdoids::BoidsPlugin;
use debug_plugin::BoidsDebugPlugin;
use crate::birdoids::{FlockingParameters, MiscParams};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
@@ -18,5 +22,9 @@ fn main() {
}))
.add_plugins(BoidsDebugPlugin)
.add_plugins(BoidsPlugin)
.add_plugins(EguiPlugin::default())
.add_plugins(ResourceInspectorPlugin::<FlockingParameters>::new()) // TODO: monitor only the flocking params resource (once it exists)
.add_plugins(ResourceInspectorPlugin::<MiscParams>::new())
.add_plugins(PhysicsPlugins::default())
.run();
}

View File

@@ -1,16 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: hsl(100, 10%, 60%);
color: hsl(0, 0%, 15%);
}
h1 {
background-color: rgb(72, 97, 72);
text-align: center;
margin: auto;
padding: 0.5em;
}
canvas {
padding-left: 0;
padding-right: 0;
margin-top: 1em;
margin-left: auto;
margin-right: auto;
display: block;
outline-color: hsl(100, 100%, 15%);
outline-style: outset;
border-radius: 8px;
background-color: rgb(40%, 40%, 40%);
}
main {
margin-left: auto;
@@ -18,11 +30,12 @@
width: 70%;
}
table {
margin-bottom: 10px;
border-collapse: collapse;
}
th, td {
border: 1px solid;
padding: 2px 4px;
padding: 0.1em 0.3em;
}
</style>
</head>
@@ -91,7 +104,7 @@
<tr>
<td>Program Version</td>
<!-- This version text is completely unchecked. I'll need to do something about that. -->
<td><code>v0.4.0</code></td>
<td><code>v0.6.0</code></td>
</tr>
</table>
</article>
@@ -99,7 +112,11 @@
<script type="module">
import init from './boids.js'
init().catch((error) => {
let compressed = await fetch("./boids_bg.wasm.gz")
let wasm_stream = compressed.body.pipeThrough(new DecompressionStream("gzip"))
let blob = await new Response(wasm_stream).blob();
init(await blob.arrayBuffer()).catch((error) => {
if (!error.message.startsWith("Using exceptions for control flow, don't mind me. This isn't actually an error!")) {
throw error;
}