Compare commits
1 commit
main
...
GameOfLife
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0297c53f2 |
25 changed files with 2146 additions and 2444 deletions
|
|
@ -1 +0,0 @@
|
||||||
.gitignore
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
dist
|
/target
|
||||||
target
|
/static
|
||||||
|
/result
|
||||||
.direnv
|
.direnv
|
||||||
|
|
|
||||||
3424
Cargo.lock
generated
3424
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
31
Cargo.toml
31
Cargo.toml
|
|
@ -1,17 +1,22 @@
|
||||||
[package]
|
[workspace]
|
||||||
edition = "2024"
|
members = ["app", "server"]
|
||||||
name = "website"
|
resolver = "3"
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bevy = { version = "0.19", default-features = false, features = ["2d", "ui"] }
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
|
# Enable more optimization in the release profile at the cost of compile time.
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
# Compile the entire crate as one unit.
|
||||||
|
# Slows compile times, marginal improvements.
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "thin"
|
# Do a second optimization pass over the entire program, including dependencies.
|
||||||
|
# Slows compile times, marginal improvements.
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
# Optimize for size in the wasm-release profile to reduce load times and bandwidth usage on web.
|
||||||
|
[profile.wasm-release]
|
||||||
|
# Default to release profile values.
|
||||||
|
inherits = "release"
|
||||||
|
# Optimize with size in mind (also try "z", sometimes it is better).
|
||||||
|
# Slightly slows compile times, great improvements to file size and runtime performance.
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
panic = "abort"
|
# Strip all debugging information from the binary to slightly reduce file size.
|
||||||
strip = true
|
strip = "debuginfo"
|
||||||
|
|
|
||||||
83
Dockerfile
83
Dockerfile
|
|
@ -1,70 +1,21 @@
|
||||||
# syntax=docker/dockerfile:1
|
FROM rust:1 AS builder
|
||||||
|
WORKDIR /usr/src/website
|
||||||
|
COPY . .
|
||||||
|
|
||||||
ARG RUST_VERSION=1
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
RUN cargo install wasm-bindgen-cli
|
||||||
|
|
||||||
|
RUN ./build-wasm.sh
|
||||||
|
RUN cargo build --release -p server
|
||||||
|
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /usr/src/website/target/release/server /usr/local/bin/server
|
||||||
|
COPY --from=builder /usr/src/website/static /app/static
|
||||||
|
|
||||||
# ---- Base: Rust toolchain + build tools ----
|
|
||||||
FROM rust:${RUST_VERSION}-slim-bookworm AS chef
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install the toolchain declared in rust-toolchain.toml (incl. wasm32 target) in its own layer
|
|
||||||
COPY rust-toolchain.toml .
|
|
||||||
RUN rustup show active-toolchain || rustup toolchain install
|
|
||||||
|
|
||||||
# Prebuilt binaries via cargo-binstall; wasm-bindgen-cli must match the version in Cargo.lock
|
|
||||||
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
|
|
||||||
|
|
||||||
# ---- Planner: compute the dependency recipe ----
|
|
||||||
FROM chef AS planner
|
|
||||||
COPY . .
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
# ---- Builder: cook deps (cached), then build the site with trunk ----
|
|
||||||
FROM chef AS builder
|
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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-alpine AS runtime
|
|
||||||
|
|
||||||
COPY <<'EOF' /etc/nginx/conf.d/default.conf
|
|
||||||
server {
|
|
||||||
listen 3000;
|
|
||||||
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 3000
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["server"]
|
||||||
|
|
|
||||||
11
Trunk.toml
11
Trunk.toml
|
|
@ -1,11 +0,0 @@
|
||||||
[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
|
|
||||||
# Server port
|
|
||||||
port = 3000
|
|
||||||
12
app/Cargo.toml
Normal file
12
app/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bevy = { version = "0.18", default-features = false, features = [
|
||||||
|
"2d",
|
||||||
|
"keyboard",
|
||||||
|
"web",
|
||||||
|
"webgl2",
|
||||||
|
] }
|
||||||
18
app/src/main.rs
Normal file
18
app/src/main.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
const ALIVE_COLOR: [u8; 4] = [0, 255, 136, 255];
|
||||||
|
const DEAD_COLOR: [u8; 4] = [18, 18, 32, 255];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
App::new()
|
||||||
|
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||||
|
primary_window: Some(Window {
|
||||||
|
title: "Game of Life".into(),
|
||||||
|
fit_canvas_to_parent: true,
|
||||||
|
canvas: Some("#bevy-canvas".into()),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
}))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
|
||||||
#import bevy_sprite::mesh2d_view_bindings::{globals, view}
|
|
||||||
|
|
||||||
// Single-scattering atmosphere (Rayleigh + Mie + ozone) with the sun fixed
|
|
||||||
// just above the horizon, plus a thin layer of drifting clouds.
|
|
||||||
|
|
||||||
const PI: f32 = 3.14159265;
|
|
||||||
|
|
||||||
const PLANET_RADIUS: f32 = 6371000.0;
|
|
||||||
const ATMOSPHERE_RADIUS: f32 = 6471000.0;
|
|
||||||
const RAYLEIGH_HEIGHT: f32 = 8000.0;
|
|
||||||
const MIE_HEIGHT: f32 = 1200.0;
|
|
||||||
const CAMERA_ALTITUDE: f32 = 200.0;
|
|
||||||
|
|
||||||
const C_RAYLEIGH: vec3<f32> = vec3<f32>(5.802, 13.558, 33.100) * 1e-6;
|
|
||||||
const C_MIE: vec3<f32> = vec3<f32>(3.996, 3.996, 3.996) * 1e-6;
|
|
||||||
const C_OZONE: vec3<f32> = vec3<f32>(0.650, 1.881, 0.085) * 1e-6;
|
|
||||||
const MIE_G: f32 = 0.76;
|
|
||||||
|
|
||||||
const VIEW_SAMPLES: i32 = 16;
|
|
||||||
const LIGHT_SAMPLES: i32 = 4;
|
|
||||||
|
|
||||||
const SUN_INTENSITY: f32 = 20.0;
|
|
||||||
const SUN_ANGULAR_RADIUS: f32 = 0.012;
|
|
||||||
// Vertical field of view is 2 * atan(FOV_SCALE)
|
|
||||||
const FOV_SCALE: f32 = 0.6;
|
|
||||||
// Screen height (in NDC) of the horizon line
|
|
||||||
const HORIZON_NDC: f32 = -0.55;
|
|
||||||
|
|
||||||
fn sun_direction() -> vec3<f32> {
|
|
||||||
// Slightly right of center, ~2 degrees above the horizon
|
|
||||||
return normalize(vec3<f32>(0.25, 0.035, -1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Near and far distances to a sphere centered at the planet center, or -1
|
|
||||||
// when the ray misses.
|
|
||||||
fn sphere_hit(origin: vec3<f32>, dir: vec3<f32>, radius: f32) -> vec2<f32> {
|
|
||||||
let b = dot(origin, dir);
|
|
||||||
let c = dot(origin, origin) - radius * radius;
|
|
||||||
let d = b * b - c;
|
|
||||||
if d < 0.0 {
|
|
||||||
return vec2<f32>(-1.0);
|
|
||||||
}
|
|
||||||
let s = sqrt(d);
|
|
||||||
return vec2<f32>(-b - s, -b + s);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn density(p: vec3<f32>) -> vec3<f32> {
|
|
||||||
let h = max(length(p) - PLANET_RADIUS, 0.0);
|
|
||||||
let ozone = max(0.0, 1.0 - abs(h - 25000.0) / 15000.0);
|
|
||||||
return vec3<f32>(exp(-h / RAYLEIGH_HEIGHT), exp(-h / MIE_HEIGHT), ozone);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn absorb(optical_depth: vec3<f32>) -> vec3<f32> {
|
|
||||||
return exp(-(optical_depth.x * C_RAYLEIGH
|
|
||||||
+ optical_depth.y * C_MIE * 1.1
|
|
||||||
+ optical_depth.z * C_OZONE));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn light_optical_depth(p: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
|
||||||
let len = sphere_hit(p, dir, ATMOSPHERE_RADIUS).y;
|
|
||||||
let step = len / f32(LIGHT_SAMPLES);
|
|
||||||
var depth = vec3<f32>(0.0);
|
|
||||||
for (var i = 0; i < LIGHT_SAMPLES; i++) {
|
|
||||||
depth += density(p + dir * (f32(i) + 0.5) * step) * step;
|
|
||||||
}
|
|
||||||
return depth;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn phase_rayleigh(c: f32) -> f32 {
|
|
||||||
return 3.0 * (1.0 + c * c) / (16.0 * PI);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn phase_hg(c: f32, g: f32) -> f32 {
|
|
||||||
let g2 = g * g;
|
|
||||||
return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * c, 1.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-scattered light along the view ray; `transmittance` receives the
|
|
||||||
// attenuation of whatever lies behind it.
|
|
||||||
fn scatter(origin: vec3<f32>, dir: vec3<f32>, sun: vec3<f32>, transmittance: ptr<function, vec3<f32>>) -> vec3<f32> {
|
|
||||||
var len = sphere_hit(origin, dir, ATMOSPHERE_RADIUS).y;
|
|
||||||
let ground = sphere_hit(origin, dir, PLANET_RADIUS);
|
|
||||||
if ground.x > 0.0 {
|
|
||||||
len = ground.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
let c = dot(dir, sun);
|
|
||||||
let phase_r = phase_rayleigh(c);
|
|
||||||
let phase_m = phase_hg(c, MIE_G);
|
|
||||||
|
|
||||||
var depth = vec3<f32>(0.0);
|
|
||||||
var rayleigh = vec3<f32>(0.0);
|
|
||||||
var mie = vec3<f32>(0.0);
|
|
||||||
var prev_t = 0.0;
|
|
||||||
for (var i = 1; i <= VIEW_SAMPLES; i++) {
|
|
||||||
// Quadratic distribution: dense samples near the camera, where the air is thick
|
|
||||||
let f = f32(i) / f32(VIEW_SAMPLES);
|
|
||||||
let t = f * f * len;
|
|
||||||
let step = t - prev_t;
|
|
||||||
let p = origin + dir * (t - 0.5 * step);
|
|
||||||
let local = density(p);
|
|
||||||
depth += local * step;
|
|
||||||
|
|
||||||
let light = absorb(depth + light_optical_depth(p, sun));
|
|
||||||
rayleigh += light * local.x * step;
|
|
||||||
mie += light * local.y * step;
|
|
||||||
prev_t = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
*transmittance = absorb(depth);
|
|
||||||
return (rayleigh * C_RAYLEIGH * phase_r + mie * C_MIE * phase_m) * SUN_INTENSITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash(p: vec2<f32>) -> f32 {
|
|
||||||
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn noise(p: vec2<f32>) -> f32 {
|
|
||||||
let i = floor(p);
|
|
||||||
let f = fract(p);
|
|
||||||
let u = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(
|
|
||||||
mix(hash(i), hash(i + vec2<f32>(1.0, 0.0)), u.x),
|
|
||||||
mix(hash(i + vec2<f32>(0.0, 1.0)), hash(i + vec2<f32>(1.0, 1.0)), u.x),
|
|
||||||
u.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fbm(p: vec2<f32>) -> f32 {
|
|
||||||
var value = 0.0;
|
|
||||||
var amplitude = 0.5;
|
|
||||||
var q = p;
|
|
||||||
for (var i = 0; i < 5; i++) {
|
|
||||||
value += amplitude * noise(q);
|
|
||||||
q *= 2.0;
|
|
||||||
amplitude *= 0.5;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
|
|
||||||
let aspect = view.viewport.z / view.viewport.w;
|
|
||||||
// uv.y is 0 at the top of the quad
|
|
||||||
let ndc = vec2<f32>(mesh.uv.x * 2.0 - 1.0, 1.0 - mesh.uv.y * 2.0);
|
|
||||||
let dir = normalize(vec3<f32>(
|
|
||||||
ndc.x * aspect * FOV_SCALE,
|
|
||||||
(ndc.y - HORIZON_NDC) * FOV_SCALE,
|
|
||||||
-1.0,
|
|
||||||
));
|
|
||||||
|
|
||||||
let sun = sun_direction();
|
|
||||||
let origin = vec3<f32>(0.0, PLANET_RADIUS + CAMERA_ALTITUDE, 0.0);
|
|
||||||
|
|
||||||
var transmittance: vec3<f32>;
|
|
||||||
var color = scatter(origin, dir, sun, &transmittance);
|
|
||||||
|
|
||||||
let cos_sun = dot(dir, sun);
|
|
||||||
if dir.y > 0.0 {
|
|
||||||
// Sun disc, reddened by the air in front of it
|
|
||||||
let disc = smoothstep(cos(SUN_ANGULAR_RADIUS * 1.2), cos(SUN_ANGULAR_RADIUS * 0.8), cos_sun);
|
|
||||||
color += disc * transmittance * SUN_INTENSITY * 20.0;
|
|
||||||
|
|
||||||
// Clouds on a flat plane, lit by the sunlight that reaches them
|
|
||||||
let p = dir.xz / dir.y * 2.0;
|
|
||||||
let t = globals.time;
|
|
||||||
let clouds = fbm(p + vec2<f32>(t * 0.02, 0.0)) * 0.6
|
|
||||||
+ fbm(p * 2.0 + vec2<f32>(t * 0.035, t * 0.008)) * 0.4;
|
|
||||||
let cover = smoothstep(0.5, 0.8, clouds) * smoothstep(0.0, 0.15, dir.y);
|
|
||||||
let sunlight = absorb(light_optical_depth(origin, sun)) * SUN_INTENSITY;
|
|
||||||
let lit = sunlight * (0.04 + phase_hg(cos_sun, 0.6) * 0.3) + color * 0.5;
|
|
||||||
color = mix(color, lit, cover * 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exposure tonemap, then dither to hide banding in the gradients
|
|
||||||
color = 1.0 - exp(-color * 2.0);
|
|
||||||
color += (hash(mesh.position.xy) - 0.5) / 255.0;
|
|
||||||
|
|
||||||
return vec4<f32>(color, 1.0);
|
|
||||||
}
|
|
||||||
27
build-wasm.sh
Executable file
27
build-wasm.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE="${1:-wasm-release}"
|
||||||
|
|
||||||
|
if [ "$PROFILE" = "dev" ]; then
|
||||||
|
PROFILE_DIR="debug"
|
||||||
|
CARGO_FLAGS=""
|
||||||
|
else
|
||||||
|
PROFILE_DIR=$PROFILE
|
||||||
|
CARGO_FLAGS="--profile wasm-release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cargo build $CARGO_FLAGS --target wasm32-unknown-unknown -p app
|
||||||
|
|
||||||
|
mkdir -p static
|
||||||
|
wasm-bindgen --out-name app \
|
||||||
|
--out-dir static \
|
||||||
|
--target web \
|
||||||
|
"target/wasm32-unknown-unknown/$PROFILE_DIR/app.wasm"
|
||||||
|
|
||||||
|
# Optimize the WASM file for size
|
||||||
|
if [ "$PROFILE" = "wasm-release" ]; then
|
||||||
|
wasm-opt -Oz -o static/app_bg.wasm static/app_bg.wasm
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp web/index.html static/
|
||||||
62
flake.lock
62
flake.lock
|
|
@ -1,30 +1,30 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-parts": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788450739,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "hercules-ci",
|
"owner": "numtide",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-utils",
|
||||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "hercules-ci",
|
"owner": "numtide",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-utils",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790323409,
|
"lastModified": 1771369470,
|
||||||
"narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=",
|
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2",
|
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -34,24 +34,9 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1788057806,
|
|
||||||
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixpkgs.lib",
|
|
||||||
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixpkgs.lib",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
|
|
@ -63,11 +48,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790407471,
|
"lastModified": 1771556776,
|
||||||
"narHash": "sha256-M8elY2UShg4ZuLTiRLEHO+ae5xQFp3/ReQdxp/R+1UU=",
|
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "4e9bb05a9ab6c37dc74c22974ebed6e36dc73eb2",
|
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -75,6 +60,21 @@
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|
|
||||||
50
flake.nix
50
flake.nix
|
|
@ -2,59 +2,43 @@
|
||||||
description = "Bevy WASM website";
|
description = "Bevy WASM website";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{
|
{
|
||||||
flake-parts,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
|
flake-utils,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-utils.lib.eachDefaultSystem (
|
||||||
systems = [
|
system:
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
"x86_64-darwin"
|
|
||||||
"aarch64-darwin"
|
|
||||||
];
|
|
||||||
|
|
||||||
perSystem =
|
|
||||||
{ pkgs, system, ... }:
|
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
rustPkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system overlays;
|
inherit system overlays;
|
||||||
};
|
};
|
||||||
rustToolchain = rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
runtimeDeps = with pkgs; [
|
extensions = [ "rust-src" ];
|
||||||
alsa-lib
|
targets = [ "wasm32-unknown-unknown" ];
|
||||||
udev
|
};
|
||||||
vulkan-loader
|
|
||||||
wayland
|
|
||||||
libxkbcommon
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
buildInputs = [
|
||||||
pkg-config
|
|
||||||
rustToolchain
|
rustToolchain
|
||||||
trunk
|
pkgs.wasm-bindgen-cli
|
||||||
|
pkgs.binaryen
|
||||||
|
pkgs.just
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = runtimeDeps;
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath runtimeDeps}"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
35
justfile
Normal file
35
justfile
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Default: build WASM app for release
|
||||||
|
default:
|
||||||
|
just build
|
||||||
|
|
||||||
|
# Build the WASM app (default: release)
|
||||||
|
build profile="wasm-release":
|
||||||
|
./build-wasm.sh {{ profile }}
|
||||||
|
|
||||||
|
# Build for development (faster compile, no wasm-opt)
|
||||||
|
build-dev:
|
||||||
|
./build-wasm.sh dev
|
||||||
|
|
||||||
|
# Build the server binary
|
||||||
|
build-server:
|
||||||
|
cargo build --release -p server
|
||||||
|
|
||||||
|
# Build everything: WASM app + server
|
||||||
|
build-all profile="wasm-release":
|
||||||
|
./build-wasm.sh {{ profile }}
|
||||||
|
cargo build --release -p server
|
||||||
|
|
||||||
|
# Serve the site locally (builds WASM first, then runs server on :3000)
|
||||||
|
serve profile="wasm-release":
|
||||||
|
./build-wasm.sh {{ profile }}
|
||||||
|
cargo run --release -p server
|
||||||
|
|
||||||
|
# Development: build WASM (dev profile) and run server with hot reload
|
||||||
|
dev:
|
||||||
|
./build-wasm.sh dev
|
||||||
|
cargo run --release -p server
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
rm -rf static/*
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
[toolchain]
|
|
||||||
channel = "1.98.1"
|
|
||||||
targets = ["wasm32-unknown-unknown"]
|
|
||||||
components = ["clippy", "rust-analyzer", "rustfmt", "rust-src"]
|
|
||||||
9
server/Cargo.toml
Normal file
9
server/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
name = "server"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
13
server/src/main.rs
Normal file
13
server/src/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(|| async { "OK" }))
|
||||||
|
.fallback_service(ServeDir::new("static"));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
218
src/boids.rs
218
src/boids.rs
|
|
@ -1,218 +0,0 @@
|
||||||
use bevy::{platform::collections::HashMap, prelude::*};
|
|
||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
config::ModelConfig,
|
|
||||||
pointer::PointerTarget,
|
|
||||||
spatial_grid::{SpatialGrid, update_spatial_grid},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct BoidsPlugin;
|
|
||||||
|
|
||||||
impl Plugin for BoidsPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.init_resource::<SpatialGrid>()
|
|
||||||
.add_systems(Startup, setup)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
sync_boid_count.run_if(resource_changed::<ModelConfig>),
|
|
||||||
)
|
|
||||||
.add_systems(FixedUpdate, (update_spatial_grid, update_boids).chain());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
pub struct Boid {
|
|
||||||
velocity: Vec2,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
struct BoidAssets {
|
|
||||||
mesh: Mesh2d,
|
|
||||||
material: MeshMaterial2d<ColorMaterial>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Triangle pointing right
|
|
||||||
let assets = BoidAssets {
|
|
||||||
mesh: Mesh2d(meshes.add(Triangle2d::new(
|
|
||||||
Vec2::new(10.0, 0.0),
|
|
||||||
Vec2::new(-3.0, 3.0),
|
|
||||||
Vec2::new(-3.0, -3.0),
|
|
||||||
))),
|
|
||||||
material: MeshMaterial2d(materials.add(Color::BLACK)),
|
|
||||||
};
|
|
||||||
spawn_boids(
|
|
||||||
&mut commands,
|
|
||||||
&assets,
|
|
||||||
0..num_boids,
|
|
||||||
num_boids,
|
|
||||||
max_velocity,
|
|
||||||
);
|
|
||||||
commands.insert_resource(assets);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_boids(
|
|
||||||
commands: &mut Commands,
|
|
||||||
assets: &BoidAssets,
|
|
||||||
range: std::ops::Range<usize>,
|
|
||||||
num_boids: usize,
|
|
||||||
max_velocity: f32,
|
|
||||||
) {
|
|
||||||
let mesh = assets.mesh.clone();
|
|
||||||
let material = assets.material.clone();
|
|
||||||
commands.spawn_batch(range.map(move |n| {
|
|
||||||
let angle = 2.0 * PI / (num_boids as f32) * n as f32;
|
|
||||||
(
|
|
||||||
Boid {
|
|
||||||
velocity: Vec2::from_angle(angle) * max_velocity,
|
|
||||||
},
|
|
||||||
mesh.clone(),
|
|
||||||
material.clone(),
|
|
||||||
Transform::default().rotate(Quat::from_rotation_z(angle)),
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns or despawns boids so that their count matches `ModelConfig::num_boids`.
|
|
||||||
fn sync_boid_count(
|
|
||||||
model_config: Res<ModelConfig>,
|
|
||||||
assets: Option<Res<BoidAssets>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
boids: Query<Entity, With<Boid>>,
|
|
||||||
) {
|
|
||||||
let Some(assets) = assets else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let current = boids.iter().len();
|
|
||||||
let target = model_config.num_boids;
|
|
||||||
if target > current {
|
|
||||||
spawn_boids(
|
|
||||||
&mut commands,
|
|
||||||
&assets,
|
|
||||||
current..target,
|
|
||||||
target,
|
|
||||||
model_config.max_velocity,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
for entity in boids.iter().take(current - target) {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>>,
|
|
||||||
grid: Res<SpatialGrid>,
|
|
||||||
pointer: Res<PointerTarget>,
|
|
||||||
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_position) in grid.neighbors(transform.translation.xy()) {
|
|
||||||
if entity == other_entity {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let delta = transform.translation.xy() - other_position;
|
|
||||||
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 {
|
|
||||||
let Ok((_, other_boid, _)) = query.get(other_entity) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
avg_speed += other_boid.velocity;
|
|
||||||
avg_pos += other_position;
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Push each boid forward along its current heading
|
|
||||||
let self_force = boid.velocity.normalize_or_zero() * model_config.self_acceleration;
|
|
||||||
|
|
||||||
// Pull towards the mouse cursor or touch point
|
|
||||||
let attraction_force = pointer.0.map_or(Vec2::ZERO, |target| {
|
|
||||||
(target - transform.translation.xy()).normalize_or_zero()
|
|
||||||
* model_config.attraction_force
|
|
||||||
});
|
|
||||||
|
|
||||||
let acceleration = boundary_force
|
|
||||||
+ separation_force
|
|
||||||
+ alignment_force
|
|
||||||
+ cohesion_force
|
|
||||||
+ self_force
|
|
||||||
+ attraction_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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub struct ConfigPlugin;
|
|
||||||
|
|
||||||
impl Plugin for ConfigPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.init_resource::<ModelConfig>()
|
|
||||||
.register_type::<ModelConfig>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Reflect, Resource)]
|
|
||||||
#[reflect(Resource)]
|
|
||||||
pub struct ModelConfig {
|
|
||||||
pub num_boids: usize,
|
|
||||||
pub x_bound: f32,
|
|
||||||
pub y_bound: f32,
|
|
||||||
pub max_velocity: f32,
|
|
||||||
pub protected_range: f32,
|
|
||||||
pub visible_range: f32,
|
|
||||||
pub alignment_factor: f32,
|
|
||||||
pub cohesion_factor: f32,
|
|
||||||
pub separation_factor: f32,
|
|
||||||
pub self_acceleration: f32,
|
|
||||||
pub attraction_force: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ModelConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
num_boids: 250,
|
|
||||||
x_bound: 0.75,
|
|
||||||
y_bound: 0.75,
|
|
||||||
max_velocity: 250.0,
|
|
||||||
protected_range: 20.0,
|
|
||||||
visible_range: 230.0,
|
|
||||||
alignment_factor: 0.3,
|
|
||||||
cohesion_factor: 0.2,
|
|
||||||
separation_factor: 500.0,
|
|
||||||
self_acceleration: 100.0,
|
|
||||||
attraction_force: 250.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/main.rs
34
src/main.rs
|
|
@ -1,34 +0,0 @@
|
||||||
mod boids;
|
|
||||||
mod config;
|
|
||||||
mod pointer;
|
|
||||||
mod sky;
|
|
||||||
mod spatial_grid;
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
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()
|
|
||||||
}))
|
|
||||||
.add_plugins((
|
|
||||||
config::ConfigPlugin,
|
|
||||||
pointer::PointerPlugin,
|
|
||||||
sky::SkyPlugin,
|
|
||||||
boids::BoidsPlugin,
|
|
||||||
ui::ConfigUiPlugin,
|
|
||||||
))
|
|
||||||
.add_systems(Startup, |mut commands: Commands| {
|
|
||||||
commands.spawn(Camera2d);
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
use bevy::{prelude::*, window::PrimaryWindow};
|
|
||||||
|
|
||||||
pub struct PointerPlugin;
|
|
||||||
|
|
||||||
impl Plugin for PointerPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.init_resource::<PointerTarget>()
|
|
||||||
.add_systems(Update, update_pointer_target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// World position of the active touch or mouse cursor, if any.
|
|
||||||
#[derive(Resource, Default)]
|
|
||||||
pub struct PointerTarget(pub Option<Vec2>);
|
|
||||||
|
|
||||||
fn update_pointer_target(
|
|
||||||
window: Single<&Window, With<PrimaryWindow>>,
|
|
||||||
camera: Single<(&Camera, &GlobalTransform)>,
|
|
||||||
touches: Res<Touches>,
|
|
||||||
mut target: ResMut<PointerTarget>,
|
|
||||||
) {
|
|
||||||
let (camera, camera_transform) = *camera;
|
|
||||||
// Touch and cursor positions are both in logical window pixels
|
|
||||||
target.0 = touches
|
|
||||||
.first_pressed_position()
|
|
||||||
.or_else(|| window.cursor_position())
|
|
||||||
.and_then(|position| camera.viewport_to_world_2d(camera_transform, position).ok());
|
|
||||||
}
|
|
||||||
51
src/sky.rs
51
src/sky.rs
|
|
@ -1,51 +0,0 @@
|
||||||
use bevy::{
|
|
||||||
prelude::*,
|
|
||||||
render::render_resource::AsBindGroup,
|
|
||||||
shader::ShaderRef,
|
|
||||||
sprite_render::{Material2d, Material2dPlugin},
|
|
||||||
window::PrimaryWindow,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct SkyPlugin;
|
|
||||||
|
|
||||||
impl Plugin for SkyPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.add_plugins(Material2dPlugin::<SkyMaterial>::default())
|
|
||||||
.add_systems(Startup, setup_sky)
|
|
||||||
.add_systems(Update, fit_sky_to_window);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Animated sky, driven by the global shader time.
|
|
||||||
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
|
||||||
struct SkyMaterial {}
|
|
||||||
|
|
||||||
impl Material2d for SkyMaterial {
|
|
||||||
fn fragment_shader() -> ShaderRef {
|
|
||||||
"shaders/sky.wgsl".into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
struct Sky;
|
|
||||||
|
|
||||||
fn setup_sky(
|
|
||||||
mut commands: Commands,
|
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
|
||||||
mut materials: ResMut<Assets<SkyMaterial>>,
|
|
||||||
) {
|
|
||||||
commands.spawn((
|
|
||||||
Sky,
|
|
||||||
Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))),
|
|
||||||
MeshMaterial2d(materials.add(SkyMaterial {})),
|
|
||||||
// Behind the boids
|
|
||||||
Transform::from_xyz(0.0, 0.0, -10.0),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fit_sky_to_window(
|
|
||||||
window: Single<&Window, (With<PrimaryWindow>, Changed<Window>)>,
|
|
||||||
mut sky: Single<&mut Transform, With<Sky>>,
|
|
||||||
) {
|
|
||||||
sky.scale = window.size().extend(1.0);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
use bevy::{platform::collections::HashMap, prelude::*};
|
|
||||||
|
|
||||||
use crate::{boids::Boid, config::ModelConfig};
|
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
|
||||||
pub struct SpatialGrid {
|
|
||||||
pub cell_size: f32,
|
|
||||||
// Maps a grid coordinate (x, y) to the boids (and their positions) in that cell
|
|
||||||
pub cells: HashMap<(i32, i32), Vec<(Entity, Vec2)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SpatialGrid {
|
|
||||||
fn cell(&self, position: Vec2) -> (i32, i32) {
|
|
||||||
let cell = (position / self.cell_size).floor();
|
|
||||||
(cell.x as i32, cell.y as i32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entries in the 3x3 block of cells around `position`. With `cell_size` at
|
|
||||||
/// least the search radius, this covers every boid within that radius.
|
|
||||||
pub fn neighbors(&self, position: Vec2) -> impl Iterator<Item = &(Entity, Vec2)> {
|
|
||||||
let (cx, cy) = self.cell(position);
|
|
||||||
(cx - 1..=cx + 1)
|
|
||||||
.flat_map(move |x| (cy - 1..=cy + 1).map(move |y| (x, y)))
|
|
||||||
.filter_map(|key| self.cells.get(&key))
|
|
||||||
.flatten()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_spatial_grid(
|
|
||||||
model_config: Res<ModelConfig>,
|
|
||||||
mut grid: ResMut<SpatialGrid>,
|
|
||||||
boids: Query<(Entity, &Transform), With<Boid>>,
|
|
||||||
) {
|
|
||||||
grid.cell_size = model_config
|
|
||||||
.visible_range
|
|
||||||
.max(model_config.protected_range)
|
|
||||||
.max(1.0);
|
|
||||||
grid.cells.clear();
|
|
||||||
for (entity, transform) in &boids {
|
|
||||||
let position = transform.translation.xy();
|
|
||||||
let cell = grid.cell(position);
|
|
||||||
grid.cells.entry(cell).or_default().push((entity, position));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
146
src/ui.rs
146
src/ui.rs
|
|
@ -1,146 +0,0 @@
|
||||||
use bevy::{prelude::*, reflect::structs::Struct};
|
|
||||||
|
|
||||||
use crate::config::ModelConfig;
|
|
||||||
|
|
||||||
pub struct ConfigUiPlugin;
|
|
||||||
|
|
||||||
impl Plugin for ConfigUiPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.add_systems(Startup, setup_ui).add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
toggle_panel,
|
|
||||||
update_config_labels.run_if(resource_changed::<ModelConfig>),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Editable `ModelConfig` fields and the amount each -/+ click changes them by.
|
|
||||||
const CONFIG_FIELDS: &[(&str, f32)] = &[
|
|
||||||
("num_boids", 5.0),
|
|
||||||
("x_bound", 0.05),
|
|
||||||
("y_bound", 0.05),
|
|
||||||
("max_velocity", 10.0),
|
|
||||||
("protected_range", 5.0),
|
|
||||||
("visible_range", 10.0),
|
|
||||||
("alignment_factor", 0.05),
|
|
||||||
("cohesion_factor", 0.05),
|
|
||||||
("separation_factor", 10.0),
|
|
||||||
("self_acceleration", 5.0),
|
|
||||||
("attraction_force", 10.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
struct ConfigPanel;
|
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
struct ConfigLabel(&'static str);
|
|
||||||
|
|
||||||
fn read_field(config: &ModelConfig, name: &str) -> String {
|
|
||||||
let field = config.field(name).unwrap();
|
|
||||||
if let Some(v) = field.try_downcast_ref::<f32>() {
|
|
||||||
format!("{v:.2}")
|
|
||||||
} else if let Some(v) = field.try_downcast_ref::<usize>() {
|
|
||||||
v.to_string()
|
|
||||||
} else {
|
|
||||||
unreachable!("unsupported ModelConfig field type for {name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn step_field(config: &mut ModelConfig, name: &str, delta: f32) {
|
|
||||||
let field = config.field_mut(name).unwrap();
|
|
||||||
if let Some(v) = field.try_downcast_mut::<f32>() {
|
|
||||||
*v = (*v + delta).max(0.0);
|
|
||||||
} else if let Some(v) = field.try_downcast_mut::<usize>() {
|
|
||||||
*v = v.saturating_add_signed(delta as isize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_ui(mut commands: Commands, model_config: Res<ModelConfig>) {
|
|
||||||
let font = TextFont::from_font_size(14.0);
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: px(10),
|
|
||||||
left: px(10),
|
|
||||||
padding: UiRect::all(px(8)),
|
|
||||||
display: Display::Grid,
|
|
||||||
// name, -, value, +
|
|
||||||
grid_template_columns: vec![RepeatedGridTrack::auto(4)],
|
|
||||||
row_gap: px(4),
|
|
||||||
column_gap: px(6),
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::all(px(6)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
|
|
||||||
Visibility::Hidden,
|
|
||||||
ConfigPanel,
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
for &(name, step) in CONFIG_FIELDS {
|
|
||||||
panel.spawn((Text::new(name), font.clone()));
|
|
||||||
spawn_step_button(panel, "-", name, -step, &font);
|
|
||||||
panel.spawn((
|
|
||||||
Text::new(read_field(&model_config, name)),
|
|
||||||
font.clone(),
|
|
||||||
TextLayout::justify(Justify::Center),
|
|
||||||
Node {
|
|
||||||
min_width: px(60),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
ConfigLabel(name),
|
|
||||||
));
|
|
||||||
spawn_step_button(panel, "+", name, step, &font);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_step_button(
|
|
||||||
parent: &mut ChildSpawnerCommands,
|
|
||||||
label: &str,
|
|
||||||
field: &'static str,
|
|
||||||
delta: f32,
|
|
||||||
font: &TextFont,
|
|
||||||
) {
|
|
||||||
parent
|
|
||||||
.spawn((
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
width: px(22),
|
|
||||||
height: px(22),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::all(px(4)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.4)),
|
|
||||||
children![(Text::new(label), font.clone())],
|
|
||||||
))
|
|
||||||
.observe(
|
|
||||||
move |_: On<Pointer<Click>>, mut config: ResMut<ModelConfig>| {
|
|
||||||
step_field(&mut config, field, delta);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows or hides the config panel when M is pressed.
|
|
||||||
fn toggle_panel(
|
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
mut panel: Single<&mut Visibility, With<ConfigPanel>>,
|
|
||||||
) {
|
|
||||||
if keys.just_pressed(KeyCode::KeyM) {
|
|
||||||
panel.toggle_visible_hidden();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_config_labels(
|
|
||||||
model_config: Res<ModelConfig>,
|
|
||||||
mut labels: Query<(&ConfigLabel, &mut Text)>,
|
|
||||||
) {
|
|
||||||
for (label, mut text) in &mut labels {
|
|
||||||
text.0 = read_field(&model_config, label.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,15 +3,33 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Personal website</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link data-trunk rel="copy-dir" href="../assets" />
|
<title>Bevy App</title>
|
||||||
<link data-trunk rel="inline" href="style.css" />
|
<style>
|
||||||
<link data-trunk rel="rust" data-cargo-no-default-features data-wasm-opt="z" data-wasm-opt-params="--all-features"
|
html,
|
||||||
href="../" />
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<canvas id="bevy"></canvas>
|
<canvas id="bevy-canvas"></canvas>
|
||||||
|
<script type="module">
|
||||||
|
import init from './app.js';
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue