Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cba02c71e | ||
|
|
df85367414 | ||
|
|
bcf542f963 |
16 changed files with 314 additions and 1310 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
|
dist
|
||||||
target
|
target
|
||||||
static
|
|
||||||
result
|
|
||||||
.direnv
|
.direnv
|
||||||
|
|
|
||||||
969
Cargo.lock
generated
969
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -1,6 +1,10 @@
|
||||||
[workspace]
|
[package]
|
||||||
members = ["app", "server"]
|
edition = "2024"
|
||||||
resolver = "3"
|
name = "website"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bevy = { version = "0.19", default-features = false, features = ["2d"] }
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
80
Dockerfile
80
Dockerfile
|
|
@ -1,26 +1,70 @@
|
||||||
FROM rust:1 AS builder
|
# syntax=docker/dockerfile:1
|
||||||
WORKDIR /usr/src/website
|
|
||||||
COPY . .
|
ARG RUST_VERSION=1.98.1
|
||||||
|
|
||||||
|
# ---- Base: Rust toolchain + build tools ----
|
||||||
|
FROM rust:${RUST_VERSION}-slim-bookworm AS chef
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends binaryen brotli yq \
|
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN rustup target add wasm32-unknown-unknown
|
# Install the toolchain declared in rust-toolchain.toml (incl. wasm32 target) in its own layer
|
||||||
RUN VERSION="$(tomlq -r '.package[] | select(.name == "wasm-bindgen") | .version' Cargo.lock)" \
|
COPY rust-toolchain.toml .
|
||||||
&& cargo install wasm-bindgen-cli --version "$VERSION"
|
RUN rustup show active-toolchain || rustup toolchain install
|
||||||
|
|
||||||
RUN ./build-wasm.sh release
|
# Prebuilt binaries via cargo-binstall; wasm-bindgen-cli must match the version in Cargo.lock
|
||||||
RUN cargo build --release -p server
|
RUN curl -L --proto '=https' --tlsv1.2 -sSf \
|
||||||
|
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash \
|
||||||
|
&& cargo binstall -y --locked cargo-chef trunk
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
# ---- Planner: compute the dependency recipe ----
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
FROM chef AS planner
|
||||||
COPY --from=builder /usr/src/website/target/release/server /usr/local/bin/server
|
COPY . .
|
||||||
COPY --from=builder /usr/src/website/static /app/static
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
WORKDIR /app
|
# ---- Builder: cook deps (cached), then build the site with trunk ----
|
||||||
EXPOSE 3000
|
FROM chef AS builder
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
CMD curl -f http://localhost:3000/health || exit 1
|
# Must match the flags trunk passes to cargo (see web/index.html) to reuse the cache
|
||||||
|
RUN cargo chef cook --release --target wasm32-unknown-unknown --no-default-features \
|
||||||
|
--recipe-path recipe.json
|
||||||
|
|
||||||
CMD ["server"]
|
COPY . .
|
||||||
|
RUN mkdir -p assets
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/trunk \
|
||||||
|
trunk build --release --locked
|
||||||
|
|
||||||
|
# ---- Runtime: static files served by nginx ----
|
||||||
|
FROM nginx:1.29-alpine AS runtime
|
||||||
|
|
||||||
|
COPY <<'EOF' /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types application/wasm application/javascript text/css;
|
||||||
|
|
||||||
|
# Bevy expects real 404s for missing assets (Trunk.toml: no_spa = true)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(wasm|js|css)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
|
||||||
9
Trunk.toml
Normal file
9
Trunk.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[build]
|
||||||
|
dist = "dist"
|
||||||
|
target = "web/index.html"
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
# Required in order to receive 404s for missing assets, which is what Bevy expects.
|
||||||
|
no_spa = true
|
||||||
|
# Open a browser tab once the initial build is complete.
|
||||||
|
open = true
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[build]
|
|
||||||
target = "wasm32-unknown-unknown"
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
[package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "app"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
avian2d = "0.7"
|
|
||||||
bevy = { version = "0.19", default-features = false, features = ["2d"] }
|
|
||||||
176
app/src/main.rs
176
app/src/main.rs
|
|
@ -1,176 +0,0 @@
|
||||||
use avian2d::prelude::*;
|
|
||||||
use bevy::{
|
|
||||||
input::common_conditions::input_just_pressed,
|
|
||||||
prelude::*,
|
|
||||||
window::{PrimaryWindow, WindowResized},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
App::new()
|
|
||||||
.insert_resource(ClearColor(Color::srgb_u8(26, 26, 46)))
|
|
||||||
.add_plugins((
|
|
||||||
DefaultPlugins.set(WindowPlugin {
|
|
||||||
primary_window: Some(Window {
|
|
||||||
fit_canvas_to_parent: true,
|
|
||||||
prevent_default_event_handling: false,
|
|
||||||
canvas: Some("#bevy-canvas".into()),
|
|
||||||
..default()
|
|
||||||
}),
|
|
||||||
..default()
|
|
||||||
}),
|
|
||||||
PhysicsPlugins::default(),
|
|
||||||
))
|
|
||||||
.insert_resource(Time::<Physics>::default().with_relative_speed(5.0))
|
|
||||||
.insert_resource(DefaultRestitution(
|
|
||||||
Restitution::PERFECTLY_ELASTIC.with_combine_rule(CoefficientCombine::Max),
|
|
||||||
))
|
|
||||||
.add_systems(Startup, setup)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
update_screen_edges,
|
|
||||||
spawn_ball.run_if(input_just_pressed(MouseButton::Left)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Component, Clone, Copy)]
|
|
||||||
enum ScreenEdge {
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup(
|
|
||||||
mut commands: Commands,
|
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
|
||||||
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
||||||
window: Query<&Window, With<PrimaryWindow>>,
|
|
||||||
) {
|
|
||||||
commands.spawn(Camera2d);
|
|
||||||
|
|
||||||
if let Ok(window) = window.single() {
|
|
||||||
spawn_screen_edges(&mut commands, &mut meshes, &mut materials, window.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Component, Clone, Copy)]
|
|
||||||
struct Ball;
|
|
||||||
|
|
||||||
fn spawn_ball(
|
|
||||||
mouse_button: Res<ButtonInput<MouseButton>>,
|
|
||||||
window: Single<&Window, With<PrimaryWindow>>,
|
|
||||||
camera_query: Single<(&Camera, &GlobalTransform)>,
|
|
||||||
time: Res<Time>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
|
||||||
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
||||||
) {
|
|
||||||
const CIRCLE_RADIUS: f32 = 5.0;
|
|
||||||
|
|
||||||
let (camera, camera_transform) = *camera_query;
|
|
||||||
if mouse_button.just_pressed(MouseButton::Left)
|
|
||||||
&& let Some(cursor_position) = window.cursor_position()
|
|
||||||
&& let Ok(world_position) = camera.viewport_to_world_2d(camera_transform, cursor_position)
|
|
||||||
{
|
|
||||||
commands.spawn((
|
|
||||||
RigidBody::Dynamic,
|
|
||||||
Collider::circle(CIRCLE_RADIUS),
|
|
||||||
LinearVelocity(Vec2::new(time.elapsed_secs().sin(), time.elapsed_secs().cos()) * 100.0),
|
|
||||||
Mesh2d(meshes.add(Circle::new(CIRCLE_RADIUS))),
|
|
||||||
MeshMaterial2d(materials.add(Color::hsl(time.elapsed_secs().sin() * 360.0, 1.0, 0.5))),
|
|
||||||
Transform::from_xyz(world_position.x, world_position.y, 0.0),
|
|
||||||
Ball,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_screen_edges(
|
|
||||||
commands: &mut Commands,
|
|
||||||
meshes: &mut Assets<Mesh>,
|
|
||||||
materials: &mut Assets<ColorMaterial>,
|
|
||||||
size: Vec2,
|
|
||||||
) {
|
|
||||||
const EDGE_COLOR: Color = Color::srgb_u8(255, 255, 255);
|
|
||||||
|
|
||||||
let material = materials.add(EDGE_COLOR);
|
|
||||||
|
|
||||||
for edge in [
|
|
||||||
ScreenEdge::Top,
|
|
||||||
ScreenEdge::Bottom,
|
|
||||||
ScreenEdge::Left,
|
|
||||||
ScreenEdge::Right,
|
|
||||||
] {
|
|
||||||
let (collider, transform, rect_size) = edge_shape(edge, size);
|
|
||||||
commands.spawn((
|
|
||||||
RigidBody::Static,
|
|
||||||
collider,
|
|
||||||
transform,
|
|
||||||
edge,
|
|
||||||
Mesh2d(meshes.add(Rectangle::from_size(rect_size))),
|
|
||||||
MeshMaterial2d(material.clone()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edge_shape(edge: ScreenEdge, size: Vec2) -> (Collider, Transform, Vec2) {
|
|
||||||
const EDGE_THICKNESS: f32 = 5.0;
|
|
||||||
|
|
||||||
let half = size / 2.0;
|
|
||||||
let thickness = EDGE_THICKNESS;
|
|
||||||
|
|
||||||
match edge {
|
|
||||||
ScreenEdge::Top => {
|
|
||||||
let rect = Vec2::new(size.x, thickness);
|
|
||||||
(
|
|
||||||
Collider::rectangle(rect.x, rect.y),
|
|
||||||
Transform::from_xyz(0.0, half.y - thickness / 2.0, 0.0),
|
|
||||||
rect,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ScreenEdge::Bottom => {
|
|
||||||
let rect = Vec2::new(size.x, thickness);
|
|
||||||
(
|
|
||||||
Collider::rectangle(rect.x, rect.y),
|
|
||||||
Transform::from_xyz(0.0, -half.y + thickness / 2.0, 0.0),
|
|
||||||
rect,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ScreenEdge::Left => {
|
|
||||||
let rect = Vec2::new(thickness, size.y);
|
|
||||||
(
|
|
||||||
Collider::rectangle(rect.x, rect.y),
|
|
||||||
Transform::from_xyz(-half.x + thickness / 2.0, 0.0, 0.0),
|
|
||||||
rect,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ScreenEdge::Right => {
|
|
||||||
let rect = Vec2::new(thickness, size.y);
|
|
||||||
(
|
|
||||||
Collider::rectangle(rect.x, rect.y),
|
|
||||||
Transform::from_xyz(half.x - thickness / 2.0, 0.0, 0.0),
|
|
||||||
rect,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_screen_edges(
|
|
||||||
mut resized: PopulatedMessageReader<WindowResized>,
|
|
||||||
mut edges: Query<(&ScreenEdge, &mut Collider, &mut Transform, &Mesh2d)>,
|
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
|
||||||
) {
|
|
||||||
for event in resized.read() {
|
|
||||||
let size = Vec2::new(event.width, event.height);
|
|
||||||
for (edge, mut collider, mut transform, mesh2d) in &mut edges {
|
|
||||||
let (new_collider, new_transform, rect_size) = edge_shape(*edge, size);
|
|
||||||
*collider = new_collider;
|
|
||||||
*transform = new_transform;
|
|
||||||
if let Some(mut mesh) = meshes.get_mut(&mesh2d.0) {
|
|
||||||
*mesh = Rectangle::from_size(rect_size).into();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROFILE="${1:-release}"
|
|
||||||
|
|
||||||
if [ "$PROFILE" = "dev" ]; then
|
|
||||||
PROFILE_DIR="debug"
|
|
||||||
CARGO_FLAGS="--features=bevy/dynamic_linking"
|
|
||||||
else
|
|
||||||
PROFILE_DIR="release"
|
|
||||||
CARGO_FLAGS="--release"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cargo build $CARGO_FLAGS --target wasm32-unknown-unknown -p app
|
|
||||||
|
|
||||||
mkdir -p static
|
|
||||||
# Drop stale bindgen / compression outputs from prior builds.
|
|
||||||
rm -f static/2dscene*
|
|
||||||
|
|
||||||
wasm-bindgen --out-name 2dscene \
|
|
||||||
--out-dir static \
|
|
||||||
--target web \
|
|
||||||
"target/wasm32-unknown-unknown/$PROFILE_DIR/app.wasm"
|
|
||||||
|
|
||||||
if [ "$PROFILE" != "dev" ]; then
|
|
||||||
wasm-opt -Oz --all-features -o static/2dscene_bg.wasm static/2dscene_bg.wasm
|
|
||||||
|
|
||||||
# Precompress assets Axum will negotiate via Accept-Encoding.
|
|
||||||
for f in static/2dscene_bg.wasm static/2dscene.js; do
|
|
||||||
gzip -9 -k -f "$f"
|
|
||||||
brotli -q 11 -f -k "$f"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
cp web/index.html static/
|
|
||||||
47
flake.nix
47
flake.nix
|
|
@ -10,7 +10,8 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = inputs@{
|
outputs =
|
||||||
|
inputs@{
|
||||||
flake-parts,
|
flake-parts,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
|
|
@ -24,43 +25,35 @@
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
];
|
];
|
||||||
|
|
||||||
perSystem = { pkgs, system, ... }:
|
perSystem =
|
||||||
|
{ pkgs, system, ... }:
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
rustPkgs = import nixpkgs {
|
rustPkgs = import nixpkgs {
|
||||||
inherit system overlays;
|
inherit system overlays;
|
||||||
};
|
};
|
||||||
rustToolchain = rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
rustToolchain = rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||||
|
runtimeDeps = with pkgs; [
|
||||||
cargoLock = fromTOML (builtins.readFile ./Cargo.lock);
|
alsa-lib
|
||||||
wasmBindgen = pkgs.lib.findFirst
|
udev
|
||||||
(pkg: pkg.name == "wasm-bindgen")
|
vulkan-loader
|
||||||
(throw "Could not find wasm-bindgen package")
|
wayland
|
||||||
cargoLock.package;
|
libxkbcommon
|
||||||
|
];
|
||||||
wasm-bindgen-cli = pkgs.buildWasmBindgenCli rec {
|
|
||||||
src = pkgs.fetchCrate {
|
|
||||||
pname = "wasm-bindgen-cli";
|
|
||||||
version = wasmBindgen.version;
|
|
||||||
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
|
||||||
inherit src;
|
|
||||||
inherit (src) pname version;
|
|
||||||
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = [
|
packages = with pkgs; [
|
||||||
|
pkg-config
|
||||||
rustToolchain
|
rustToolchain
|
||||||
wasm-bindgen-cli
|
trunk
|
||||||
pkgs.binaryen
|
|
||||||
pkgs.brotli
|
|
||||||
pkgs.just
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
buildInputs = runtimeDeps;
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath runtimeDeps}"
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
35
justfile
35
justfile
|
|
@ -1,35 +0,0 @@
|
||||||
# List available recipes
|
|
||||||
default:
|
|
||||||
@just --list
|
|
||||||
|
|
||||||
# Build the Bevy app to WASM (release by default; use `just build dev` for debug)
|
|
||||||
build profile="release":
|
|
||||||
./build-wasm.sh {{profile}}
|
|
||||||
|
|
||||||
# Build WASM in debug mode
|
|
||||||
build-dev:
|
|
||||||
./build-wasm.sh dev
|
|
||||||
|
|
||||||
# Build the HTTP server (release)
|
|
||||||
build-server:
|
|
||||||
cargo build --release -p server
|
|
||||||
|
|
||||||
# Build WASM + server for production
|
|
||||||
build-all: build build-server
|
|
||||||
|
|
||||||
# Run the server (serves ./static on :3000)
|
|
||||||
serve:
|
|
||||||
cargo run -p server
|
|
||||||
|
|
||||||
# Build WASM then run the server
|
|
||||||
run: build-dev serve
|
|
||||||
|
|
||||||
# Type-check the workspace
|
|
||||||
check:
|
|
||||||
cargo clippy --target wasm32-unknown-unknown -p app
|
|
||||||
cargo clippy --target x86_64-unknown-linux-gnu -p server
|
|
||||||
|
|
||||||
# Remove build artifacts
|
|
||||||
clean:
|
|
||||||
rm -rf static
|
|
||||||
cargo clean
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "server"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
axum = "0.8"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
use axum::{Router, routing::get};
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
let static_files = ServeDir::new("static")
|
|
||||||
.precompressed_gzip()
|
|
||||||
.precompressed_br();
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/health", get(|| async { "OK" }))
|
|
||||||
.fallback_service(static_files);
|
|
||||||
|
|
||||||
println!("Listening on http://0.0.0.0:3000");
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
}
|
|
||||||
177
src/main.rs
Normal file
177
src/main.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
use bevy::{platform::collections::HashMap, prelude::*};
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
#[derive(Reflect, Resource)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
struct ModelConfig {
|
||||||
|
num_boids: usize,
|
||||||
|
x_bound: f32,
|
||||||
|
y_bound: f32,
|
||||||
|
max_velocity: f32,
|
||||||
|
protected_range: f32,
|
||||||
|
visible_range: f32,
|
||||||
|
alignment_factor: f32,
|
||||||
|
cohesion_factor: f32,
|
||||||
|
separation_factor: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
num_boids: 50,
|
||||||
|
x_bound: 0.7,
|
||||||
|
y_bound: 0.7,
|
||||||
|
max_velocity: 200.0,
|
||||||
|
protected_range: 50.0,
|
||||||
|
visible_range: 150.0,
|
||||||
|
alignment_factor: 0.5,
|
||||||
|
cohesion_factor: 0.5,
|
||||||
|
separation_factor: 200.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
App::new()
|
||||||
|
.insert_resource(ClearColor(Color::srgb_u8(26, 26, 46)))
|
||||||
|
.insert_resource(Time::<Fixed>::from_hz(60.0))
|
||||||
|
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||||
|
primary_window: Some(Window {
|
||||||
|
fit_canvas_to_parent: true,
|
||||||
|
prevent_default_event_handling: false,
|
||||||
|
canvas: Some("#bevy".into()),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
}))
|
||||||
|
.init_resource::<ModelConfig>()
|
||||||
|
.register_type::<ModelConfig>()
|
||||||
|
.add_systems(Startup, setup)
|
||||||
|
.add_systems(FixedUpdate, update_boids)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct Boid {
|
||||||
|
velocity: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(
|
||||||
|
model_config: Res<ModelConfig>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
||||||
|
) {
|
||||||
|
let ModelConfig {
|
||||||
|
num_boids,
|
||||||
|
max_velocity,
|
||||||
|
..
|
||||||
|
} = *model_config;
|
||||||
|
|
||||||
|
commands.spawn(Camera2d);
|
||||||
|
|
||||||
|
// Triangle pointing right
|
||||||
|
let triangle_mesh = Mesh2d(meshes.add(Triangle2d::new(
|
||||||
|
Vec2::new(10.0, 0.0),
|
||||||
|
Vec2::new(-3.0, 3.0),
|
||||||
|
Vec2::new(-3.0, -3.0),
|
||||||
|
)));
|
||||||
|
let triangle_material = MeshMaterial2d(materials.add(Color::WHITE));
|
||||||
|
commands.spawn_batch((0..num_boids).map(move |n| {
|
||||||
|
let angle = 2.0 * PI / (num_boids as f32) * n as f32;
|
||||||
|
(
|
||||||
|
Boid {
|
||||||
|
velocity: Vec2::from_angle(angle) * max_velocity,
|
||||||
|
},
|
||||||
|
triangle_mesh.clone(),
|
||||||
|
triangle_material.clone(),
|
||||||
|
Transform::default().rotate(Quat::from_rotation_z(angle)),
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Model {
|
||||||
|
separation: Vec2,
|
||||||
|
alignment: Vec2,
|
||||||
|
cohesion: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_boids(
|
||||||
|
model_config: Res<ModelConfig>,
|
||||||
|
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
time: Res<Time<Fixed>>,
|
||||||
|
mut query: Query<(Entity, &mut Boid, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let x_bound = window.width() * model_config.x_bound;
|
||||||
|
let y_bound = window.height() * model_config.y_bound;
|
||||||
|
|
||||||
|
let mut model_updates = HashMap::new();
|
||||||
|
for (entity, boid, transform) in query.iter() {
|
||||||
|
let mut nb_neighbors: usize = 0;
|
||||||
|
let mut separation = Vec2::new(0.0, 0.0);
|
||||||
|
let mut avg_speed = Vec2::new(0.0, 0.0);
|
||||||
|
let mut avg_pos = Vec2::new(0.0, 0.0);
|
||||||
|
for (other_entity, other_boid, other_transform) in query.iter() {
|
||||||
|
if entity == other_entity {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let delta = transform.translation.xy() - other_transform.translation.xy();
|
||||||
|
let distance = delta.length();
|
||||||
|
if distance < model_config.protected_range {
|
||||||
|
if distance > f32::EPSILON {
|
||||||
|
let direction = delta / distance;
|
||||||
|
let strength = 1.0 - distance / model_config.protected_range;
|
||||||
|
separation += direction * strength;
|
||||||
|
}
|
||||||
|
} else if distance < model_config.visible_range {
|
||||||
|
avg_speed += other_boid.velocity;
|
||||||
|
avg_pos += other_transform.translation.xy();
|
||||||
|
nb_neighbors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nb_neighbors > 0 {
|
||||||
|
avg_speed /= nb_neighbors as f32;
|
||||||
|
avg_pos /= nb_neighbors as f32;
|
||||||
|
}
|
||||||
|
model_updates.insert(
|
||||||
|
entity,
|
||||||
|
Model {
|
||||||
|
separation,
|
||||||
|
alignment: avg_speed - boid.velocity,
|
||||||
|
cohesion: avg_pos - transform.translation.xy(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, mut boid, mut transform) in query.iter_mut() {
|
||||||
|
let mut out_of_bounds_acceleration = Vec2::new(0.0, 0.0);
|
||||||
|
if transform.translation.x < -x_bound / 2.0 {
|
||||||
|
out_of_bounds_acceleration.x = 1.0;
|
||||||
|
} else if transform.translation.x > x_bound / 2.0 {
|
||||||
|
out_of_bounds_acceleration.x = -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if transform.translation.y < -y_bound / 2.0 {
|
||||||
|
out_of_bounds_acceleration.y = 1.0;
|
||||||
|
} else if transform.translation.y > y_bound / 2.0 {
|
||||||
|
out_of_bounds_acceleration.y = -1.0;
|
||||||
|
}
|
||||||
|
let out_of_bounds_acceleration = out_of_bounds_acceleration.normalize_or_zero();
|
||||||
|
|
||||||
|
let model_update = model_updates.get(&entity).unwrap();
|
||||||
|
|
||||||
|
let boundary_force = out_of_bounds_acceleration * model_config.max_velocity;
|
||||||
|
let separation_force = model_update.separation * model_config.separation_factor;
|
||||||
|
let alignment_force = model_update.alignment * model_config.alignment_factor;
|
||||||
|
let cohesion_force = model_update.cohesion * model_config.cohesion_factor;
|
||||||
|
|
||||||
|
let acceleration = boundary_force + separation_force + alignment_force + cohesion_force;
|
||||||
|
boid.velocity += acceleration * dt;
|
||||||
|
boid.velocity = boid.velocity.clamp_length_max(model_config.max_velocity);
|
||||||
|
|
||||||
|
transform.translation.x += boid.velocity.x * dt;
|
||||||
|
transform.translation.y += boid.velocity.y * dt;
|
||||||
|
transform.rotation = Quat::from_rotation_z(boid.velocity.y.atan2(boid.velocity.x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,29 +3,15 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Personal website</title>
|
||||||
<title>Personal Website</title>
|
<link data-trunk rel="copy-dir" href="../assets" />
|
||||||
<style>
|
<link data-trunk rel="inline" href="style.css" />
|
||||||
html,
|
<link data-trunk rel="rust" data-cargo-no-default-features data-wasm-opt="z" data-wasm-opt-params="--all-features"
|
||||||
body {
|
href="../" />
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<canvas id="bevy-canvas"></canvas>
|
<canvas id="bevy"></canvas>
|
||||||
<script type="module">
|
|
||||||
import init from './2dscene.js';
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
11
web/style.css
Normal file
11
web/style.css
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue