This commit is contained in:
John Gatward
2026-03-18 15:24:08 +00:00
parent 213cf5f535
commit 3cb8d5a14e
17 changed files with 1525 additions and 331 deletions

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flocking Boids - WASM</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<aside id="controls" aria-expanded="true">
<div id="controlsHeader">
<h2 id="controlsTitle">Flocking Controls</h2>
<button id="controlsToggle" type="button" aria-controls="controlsBody" aria-expanded="true" aria-label="Collapse controls"></button>
</div>
<div id="controlsBody">
<div class="control-group">
<label for="alignSlider">Alignment</label>
<input type="range" id="alignSlider" min="0" max="5" step="0.1" value="1.0">
<div class="value-display">
<span class="stat-label">Weight:</span>
<strong id="alignValue">1.00</strong>
</div>
</div>
<div class="control-group">
<label for="cohesionSlider">Cohesion</label>
<input type="range" id="cohesionSlider" min="0" max="5" step="0.1" value="0.9">
<div class="value-display">
<span class="stat-label">Weight:</span>
<strong id="cohesionValue">0.90</strong>
</div>
</div>
<div class="control-group">
<label for="separationSlider">Separation</label>
<input type="range" id="separationSlider" min="0" max="5" step="0.1" value="1.4">
<div class="value-display">
<span class="stat-label">Weight:</span>
<strong id="separationValue">1.40</strong>
</div>
</div>
<div class="divider"></div>
<div class="control-group">
<label for="addBtn">Boid Count (Step: 10)</label>
<div class="button-group">
<button id="removeBtn" type="button">10 Remove</button>
<button id="addBtn" type="button">+10 Add</button>
</div>
<div class="value-display">
<span class="stat-label">Total:</span>
<strong id="boidCount">250</strong>
</div>
</div>
<div class="divider"></div>
<div class="stats">
<div class="stat-item">
<span class="stat-label">Canvas:</span>
<span class="stat-value" id="canvasSize">0x0</span>
</div>
<div class="stat-item">
<span class="stat-label">FPS:</span>
<span class="stat-value" id="fps">60</span>
</div>
</div>
</div>
</aside>
</div>
<script type="module" src="script.js"></script>
</body>
</html>

241
projects/flocking/script.js Normal file
View File

@@ -0,0 +1,241 @@
import init, { SimulationWasm } from './pkg/flocking.js';
const BOIDS_PER_1920X1080 = 250;
const MIN_INITIAL_BOIDS = 100;
const MAX_INITIAL_BOIDS = 1000;
const BOID_STEP = 10;
let wasm = null;
let sim = null;
let canvas = null;
let ctx = null;
let controls = null;
let controlsToggle = null;
let controlsResizeObserver = null;
let frameCount = 0;
let lastFpsUpdate = Date.now();
let displayWidth = 1;
let displayHeight = 1;
let resizeAnimationFrame = 0;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getInitialBoidCount(width, height) {
const areaScale = (width * height) / (1920 * 1080);
const targetCount = Math.round(BOIDS_PER_1920X1080 * areaScale);
const clampedCount = clamp(targetCount, MIN_INITIAL_BOIDS, MAX_INITIAL_BOIDS);
return Math.round(clampedCount / BOID_STEP) * BOID_STEP;
}
function syncBoidCountToViewport(width, height) {
const targetCount = getInitialBoidCount(width, height);
let currentCount = sim.get_boid_count();
while (currentCount < targetCount) {
sim.add_boid();
currentCount = sim.get_boid_count();
}
while (currentCount > targetCount) {
sim.remove_boid();
currentCount = sim.get_boid_count();
}
updateBoidCount();
}
function updateControlsCollapsedState(isCollapsed) {
if (controls === null || controlsToggle === null) {
return;
}
controls.classList.toggle('collapsed', isCollapsed);
controls.setAttribute('aria-expanded', String(!isCollapsed));
controlsToggle.setAttribute('aria-expanded', String(!isCollapsed));
controlsToggle.setAttribute('aria-label', isCollapsed ? 'Expand controls' : 'Collapse controls');
controlsToggle.textContent = isCollapsed ? '' : '';
}
function sizeCanvasToViewport() {
const dpr = window.devicePixelRatio || 1;
const nextDisplayWidth = Math.max(1, Math.floor(canvas.clientWidth));
const nextDisplayHeight = Math.max(1, Math.floor(canvas.clientHeight));
const nextBufferWidth = Math.max(1, Math.round(nextDisplayWidth * dpr));
const nextBufferHeight = Math.max(1, Math.round(nextDisplayHeight * dpr));
if (canvas.width !== nextBufferWidth || canvas.height !== nextBufferHeight) {
canvas.width = nextBufferWidth;
canvas.height = nextBufferHeight;
}
displayWidth = nextDisplayWidth;
displayHeight = nextDisplayHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
document.getElementById('canvasSize').textContent = `${displayWidth}x${displayHeight}`;
return { width: displayWidth, height: displayHeight };
}
function scheduleResize() {
if (resizeAnimationFrame !== 0) {
return;
}
resizeAnimationFrame = window.requestAnimationFrame(() => {
resizeAnimationFrame = 0;
const { width, height } = sizeCanvasToViewport();
if (sim !== null) {
sim.resize(width, height);
syncBoidCountToViewport(width, height);
}
});
}
function setupResizeObserver() {
controlsResizeObserver = new ResizeObserver(() => {
scheduleResize();
});
controlsResizeObserver.observe(document.getElementById('container'));
controlsResizeObserver.observe(controls);
}
async function run() {
wasm = await init();
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
controls = document.getElementById('controls');
controlsToggle = document.getElementById('controlsToggle');
updateControlsCollapsedState(false);
const { width, height } = sizeCanvasToViewport();
sim = new SimulationWasm(width, height, getInitialBoidCount(width, height));
setupControls();
setupResizeObserver();
updateBoidCount();
window.addEventListener('resize', scheduleResize);
animate();
}
function setupControls() {
const alignSlider = document.getElementById('alignSlider');
const cohesionSlider = document.getElementById('cohesionSlider');
const separationSlider = document.getElementById('separationSlider');
const addBtn = document.getElementById('addBtn');
const removeBtn = document.getElementById('removeBtn');
controlsToggle.addEventListener('click', () => {
const isCollapsed = !controls.classList.contains('collapsed');
updateControlsCollapsedState(isCollapsed);
scheduleResize();
});
alignSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_align_mult(value);
document.getElementById('alignValue').textContent = value.toFixed(2);
});
cohesionSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_cohesion_mult(value);
document.getElementById('cohesionValue').textContent = value.toFixed(2);
});
separationSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_separation_mult(value);
document.getElementById('separationValue').textContent = value.toFixed(2);
});
addBtn.addEventListener('click', () => {
sim.add_boid();
updateBoidCount();
});
removeBtn.addEventListener('click', () => {
sim.remove_boid();
updateBoidCount();
});
}
function updateBoidCount() {
document.getElementById('boidCount').textContent = sim.get_boid_count();
}
function getBoidView() {
const ptr = sim.boid_buffer_ptr();
const len = sim.boid_buffer_len();
return new Float32Array(wasm.memory.buffer, ptr, len);
}
function animate() {
ctx.fillStyle = '#121212';
ctx.fillRect(0, 0, displayWidth, displayHeight);
sim.step();
const boidData = getBoidView();
const stride = sim.boid_buffer_stride();
for (let i = 0; i < boidData.length; i += stride) {
const x = boidData[i];
const y = boidData[i + 1];
const vx = boidData[i + 2];
const vy = boidData[i + 3];
const r = boidData[i + 4];
const g = boidData[i + 5];
const b = boidData[i + 6];
const a = boidData[i + 7];
const speedSq = vx * vx + vy * vy;
let dx;
let dy;
if (speedSq > 1e-6) {
const invSpeed = 1 / Math.sqrt(speedSq);
dx = vx * invSpeed;
dy = vy * invSpeed;
} else {
dx = 0;
dy = -1;
}
const size = 5;
const headMult = 3;
const px = -dy;
const py = dx;
const tipX = x + dx * size * headMult;
const tipY = y + dy * size * headMult;
const baseX = x - dx * size;
const baseY = y - dy * size;
const leftX = baseX + px * size;
const leftY = baseY + py * size;
const rightX = baseX - px * size;
const rightY = baseY - py * size;
ctx.strokeStyle = `rgba(${r},${g},${b},${a / 255})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(leftX, leftY);
ctx.lineTo(rightX, rightY);
ctx.closePath();
ctx.stroke();
}
frameCount += 1;
const now = Date.now();
if (now - lastFpsUpdate >= 1000) {
document.getElementById('fps').textContent = frameCount;
frameCount = 0;
lastFpsUpdate = now;
}
requestAnimationFrame(animate);
}
await run();

239
projects/flocking/style.css Normal file
View File

@@ -0,0 +1,239 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--panel-width: 280px;
--panel-collapsed-width: 44px;
--panel-transition: 180ms ease;
}
html, body {
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #121212;
color: #d9d9d9;
overflow: hidden;
}
#container {
display: flex;
width: 100vw;
height: 100vh;
}
#canvas {
flex: 1;
min-width: 0;
min-height: 0;
display: block;
background: #121212;
}
#controls {
width: var(--panel-width);
background: #1e1e1e;
border-left: 1px solid #333;
overflow: hidden;
display: flex;
flex-direction: column;
transition: width var(--panel-transition), min-width var(--panel-transition);
min-width: var(--panel-width);
}
#controls.collapsed {
width: var(--panel-collapsed-width);
min-width: var(--panel-collapsed-width);
}
#controlsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 12px 12px 16px;
border-bottom: 1px solid #333;
}
#controlsTitle {
font-size: 16px;
color: #d9d9d9;
white-space: nowrap;
}
#controlsToggle {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border: 1px solid #333;
background: #2a2a2a;
color: #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all 0.2s;
}
#controlsToggle:hover {
background: #3a3a3a;
border-color: #4a9eff;
}
#controlsBody {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
opacity: 1;
transition: opacity 120ms ease;
}
#controls.collapsed #controlsBody {
opacity: 0;
pointer-events: none;
}
#controls.collapsed #controlsTitle {
display: none;
}
.control-group {
display: flex;
flex-direction: column;
gap: 10px;
}
label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #a0a0a0;
}
input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: #333;
outline: none;
-webkit-appearance: none;
appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4a9eff;
cursor: pointer;
transition: background 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #2196f3;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #4a9eff;
cursor: pointer;
border: none;
transition: background 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #2196f3;
}
.value-display {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.value-display strong {
color: #4a9eff;
font-weight: 600;
}
.button-group {
display: flex;
gap: 10px;
}
button {
flex: 1;
padding: 10px 16px;
border: 1px solid #333;
background: #2a2a2a;
color: #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
button:hover {
background: #3a3a3a;
border-color: #4a9eff;
}
button:active {
background: #1a1a1a;
}
.stats {
padding: 12px;
background: #252525;
border-radius: 4px;
border-left: 3px solid #4a9eff;
font-size: 13px;
}
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.stat-item:last-child {
margin-bottom: 0;
}
.stat-label {
color: #a0a0a0;
}
.stat-value {
color: #4a9eff;
font-weight: 600;
font-family: 'Courier New', monospace;
}
#fps {
color: #4a9eff;
font-size: 13px;
font-family: 'Courier New', monospace;
}
.divider {
height: 1px;
background: #333;
}