tsp
This commit is contained in:
78
projects/flocking/index.html
Normal file
78
projects/flocking/index.html
Normal 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
241
projects/flocking/script.js
Normal 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
239
projects/flocking/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user