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

@@ -228,17 +228,30 @@
<span class="card-desc">A small tile game created inspired by a childhood game.</span>
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/tsp/index.html">
<span class="card-date">20 Mar 2024</span>
<span class="card-title">Travelling Salesman Problem</span>
<span class="card-desc">A* Pathfinding algorithm, backend in zig, rendered with WebGL.</span>
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/flocking/index.html">
<span class="card-date">31 Jan 2024</span>
<span class="card-title">Flocking</span>
<span class="card-desc">A flocking simulator, inspired by The Coding Train's video.</span>
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/cellular_automata/index.html">
<span class="card-date">23 Jul 2023</span>
<span class="card-date">21 Jul 2023</span>
<span class="card-title">Game of Life</span>
<span class="card-desc">After previously implementing game of life in p5.js, I implemented a generic
simulator, for more obsquere automata.</span>
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/percolation/index.html">
<span class="card-date">2 Feb 2022</span>
<span class="card-date">5 Feb 2022</span>
<span class="card-title">Percolation</span>
<span class="card-desc">A small demonstration to show liquid percolating through a medium as more cracks appear - and at what p value this happens.</span>
<span class="card-desc">A small demonstration to show liquid percolating through a medium as more cracks
appear - and at what p value this happens.</span>
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/cubic_bezier_curve/index.html">
@@ -249,7 +262,7 @@
<span class="card-arrow"></span>
</a>
<a class="card" href="projects/marching_squares.html">
<span class="card-date">23 Sep 2021</span>
<span class="card-date">17 Sep 2021</span>
<span class="card-title">2D Marching squares</span>
<span class="card-desc">Marching squares in action — click a tile to edit the terrain.</span>
<span class="card-arrow"></span>

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;
}

View File

@@ -1,32 +1,30 @@
const statusElement = document.getElementById("status");
const progressElement = document.getElementById("progress");
const spinnerElement = document.getElementById("spinner");
const canvasElement = document.getElementById("canvas");
const outputElement = document.getElementById("output");
const statusElement = document.getElementById('status');
const progressElement = document.getElementById('progress');
const canvasElement = document.getElementById('canvas');
const outputElement = document.getElementById('output');
const fullscreenButton = document.getElementById('fullscreenButton');
const resizeCheckbox = document.getElementById('resize');
const pointerLockCheckbox = document.getElementById('pointerLock');
if (outputElement) {
outputElement.value = "";
outputElement.value = '';
}
canvasElement.addEventListener(
"webglcontextlost",
(event) => {
alert("WebGL context lost. You will need to reload the page.");
event.preventDefault();
},
false
);
canvasElement.addEventListener('webglcontextlost', (event) => {
event.preventDefault();
setStatus('WebGL context lost. Reload the page to restart the game.');
}, false);
function setStatus(text) {
if (!setStatus.last) {
setStatus.last = { time: Date.now(), text: "" };
setStatus.last = { time: Date.now(), text: '' };
}
if (text === setStatus.last.text) {
return;
}
const match = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
const match = text?.match(/([^(]+)\((\d+(?:\.\d+)?)\/(\d+)\)/);
const now = Date.now();
if (match && now - setStatus.last.time < 30) {
@@ -37,58 +35,55 @@ function setStatus(text) {
setStatus.last.text = text;
if (match) {
statusElement.innerHTML = match[1].trim();
progressElement.value = 100 * parseInt(match[2], 10);
progressElement.max = 100 * parseInt(match[4], 10);
statusElement.textContent = match[1].trim();
progressElement.value = Number.parseInt(match[2], 10) * 100;
progressElement.max = Number.parseInt(match[3], 10) * 100;
progressElement.hidden = false;
spinnerElement.hidden = false;
} else {
statusElement.innerHTML = text;
progressElement.value = null;
progressElement.max = null;
progressElement.hidden = true;
statusElement.textContent = text || '';
progressElement.hidden = !text;
if (!text) {
spinnerElement.style.display = "none";
progressElement.removeAttribute('value');
}
}
}
var Module = window.Module || {};
Module.print = (...args) => {
globalThis.Module = globalThis.Module || {};
globalThis.Module.canvas = canvasElement;
globalThis.Module.print = (...args) => {
console.log(...args);
if (!outputElement) {
return;
}
const text = args.join(" ");
outputElement.value += text + "\n";
outputElement.value += `${args.join(' ')}\n`;
outputElement.scrollTop = outputElement.scrollHeight;
};
Module.canvas = canvasElement;
Module.setStatus = setStatus;
Module.totalDependencies = 0;
Module.monitorRunDependencies = function (left) {
globalThis.Module.printErr = (...args) => {
console.error(...args);
if (!outputElement) {
return;
}
outputElement.value += `[err] ${args.join(' ')}\n`;
outputElement.scrollTop = outputElement.scrollHeight;
};
globalThis.Module.setStatus = setStatus;
globalThis.Module.totalDependencies = 0;
globalThis.Module.monitorRunDependencies = function (left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
setStatus(
left
? "Preparing... (" + (this.totalDependencies - left) + "/" + this.totalDependencies + ")"
: "All downloads complete."
);
setStatus(left ? `Preparing... (${this.totalDependencies - left}/${this.totalDependencies})` : 'Running...');
if (!left) {
setTimeout(() => setStatus(''), 250);
}
};
setStatus("Downloading...");
fullscreenButton.addEventListener('click', () => {
if (typeof globalThis.Module.requestFullscreen === 'function') {
globalThis.Module.requestFullscreen(pointerLockCheckbox.checked, resizeCheckbox.checked);
}
});
window.onerror = (message) => {
setStatus("Exception thrown, see JavaScript console");
spinnerElement.style.display = "none";
setStatus = (text) => {
if (text) {
console.error("[post-exception status] " + text);
}
};
return message;
setStatus('Downloading...');
globalThis.onerror = () => {
setStatus('Exception thrown, see JavaScript console');
};
window.Module = Module;

View File

@@ -1,295 +1,259 @@
:root {
--bg-0: #0b1220;
--bg-1: #13213a;
--bg-2: #1b2f4d;
--ice: #cfeeff;
--snow: #f5fbff;
--accent: #7fd8ff;
--accent-2: #a7e8ff;
--text: #e9f2ff;
--muted: #b6c8df;
--ok: #9cf0b4;
--warn: #ffd47e;
--shadow: rgba(0, 0, 0, 0.35);
--card: rgba(255, 255, 255, 0.06);
--card-border: rgba(255, 255, 255, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
:root {
--bg: #fffdf7;
--text: #0f172a;
--muted: #475569;
--border: #0f172a;
--panel: #dff4ff;
--panel-2: #ffffff;
--accent: #00a6fb;
--accent-strong: #0077b6;
--highlight: #ffd23f;
--danger: #ff5d73;
}
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
background: radial-gradient(circle at 15% 10%, #223a5f 0%, var(--bg-1) 28%, var(--bg-0) 100%);
font-family: "Trebuchet MS", "Segoe UI", Arial, sans-serif;
image-rendering: pixelated;
font-family: Arial, Helvetica, sans-serif;
font-size: 18px;
line-height: 1.6;
padding: 20px;
}
body {
display: flex;
justify-content: center;
padding: 2rem 1rem 3rem;
position: relative;
overflow-x: hidden;
.container {
max-width: 920px;
margin: 0 auto;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 3px solid var(--border);
padding-bottom: 20px;
}
body::before {
background-image:
radial-gradient(2px 2px at 15% 20%, rgba(255, 255, 255, 0.7) 50%, transparent 51%),
radial-gradient(2px 2px at 35% 40%, rgba(255, 255, 255, 0.5) 50%, transparent 51%),
radial-gradient(2px 2px at 65% 15%, rgba(255, 255, 255, 0.6) 50%, transparent 51%),
radial-gradient(2px 2px at 80% 75%, rgba(255, 255, 255, 0.7) 50%, transparent 51%),
radial-gradient(2px 2px at 25% 85%, rgba(255, 255, 255, 0.55) 50%, transparent 51%);
opacity: 0.35;
}
body::after {
background: linear-gradient(180deg, transparent 0%, rgba(181, 227, 255, 0.08) 100%);
}
.page {
width: min(1100px, 100%);
position: relative;
z-index: 1;
display: grid;
gap: 1rem;
}
.hero,
.panel {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 14px;
box-shadow: 0 10px 35px var(--shadow);
backdrop-filter: blur(3px);
}
.hero {
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
}
.title-group h1 {
margin: 0;
font-size: clamp(1.5rem, 2.3vw, 2rem);
h1,
h2,
h3,
button,
summary,
.back-button {
font-family: "Arial Black", Arial, Helvetica, sans-serif;
font-weight: 900;
letter-spacing: 0.02em;
}
.title-group p {
margin: 0.3rem 0 0;
color: var(--muted);
max-width: 60ch;
font-size: 0.95rem;
line-height: 1.4;
h1 {
font-size: clamp(2.4rem, 5vw, 3.6rem);
margin-bottom: 10px;
line-height: 1;
text-transform: uppercase;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pill {
border: 1px solid rgba(167, 232, 255, 0.35);
background: rgba(127, 216, 255, 0.12);
color: var(--snow);
padding: 0.35rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
white-space: nowrap;
}
.layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
.game-shell {
padding: 1rem;
display: grid;
gap: 0.8rem;
}
.status-row {
display: grid;
gap: 0.5rem;
}
.loading {
h2 {
font-size: 1.8rem;
margin: 0 0 16px;
}
h3 {
font-size: 1.35rem;
margin: 0 0 14px;
}
p,
ul {
margin-bottom: 16px;
}
.subtitle {
color: var(--accent-strong);
font-size: 1rem;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 15px;
background: var(--highlight);
color: var(--text);
text-decoration: none;
border: 3px solid var(--border);
}
.back-button:hover,
.action-button:hover {
background: var(--text);
color: #fff;
}
.info-box {
background: var(--highlight);
border: 3px solid var(--border);
padding: 16px;
margin: 20px 0;
}
.canvas-container {
background: var(--panel);
border: 3px solid var(--border);
padding: 20px;
margin: 30px 0;
}
.canvas-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.55rem;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.canvas-toolbar p {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
min-height: 1.1rem;
font-size: 0.95rem;
text-transform: uppercase;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--accent-2);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex: 0 0 auto;
.action-button {
padding: 10px 14px;
background: var(--danger);
color: white;
border: 3px solid var(--border);
cursor: pointer;
font: inherit;
}
#status {
font-weight: 600;
color: var(--ice);
}
#progress {
width: 100%;
height: 12px;
border: 0;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.15);
}
#progress::-webkit-progress-bar {
background: rgba(255, 255, 255, 0.15);
}
#progress::-webkit-progress-value {
background: linear-gradient(90deg, #95dfff, #d8f4ff);
}
#progress::-moz-progress-bar {
background: linear-gradient(90deg, #95dfff, #d8f4ff);
}
.canvas-frame {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 0.6rem;
.canvas-shell {
display: flex;
justify-content: center;
align-items: center;
min-height: 520px;
overflow: auto;
background: var(--panel-2);
border: 3px solid var(--border);
padding: 12px;
}
#canvas {
display: block;
width: 100%;
max-width: 100%;
margin: 0 auto;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
max-width: 100%;
height: auto;
background: #000;
outline: none;
image-rendering: pixelated;
}
.controls {
.canvas-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
gap: 14px;
margin-top: 15px;
font-size: 0.95rem;
color: var(--muted);
font-size: 0.9rem;
}
.controls label {
.canvas-options label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
user-select: none;
cursor: pointer;
gap: 8px;
}
.controls input[type="checkbox"] {
accent-color: #8ad6ff;
cursor: pointer;
.canvas-options input {
accent-color: var(--accent-strong);
}
.btn {
border: 1px solid rgba(167, 232, 255, 0.6);
color: var(--snow);
background: linear-gradient(180deg, rgba(146, 217, 255, 0.35), rgba(88, 173, 222, 0.3));
border-radius: 8px;
padding: 0.42rem 0.7rem;
font-weight: 600;
cursor: pointer;
.status {
margin-top: 15px;
min-height: 24px;
color: var(--accent-strong);
font-weight: 700;
}
.btn:hover {
filter: brightness(1.08);
progress {
width: 100%;
height: 18px;
margin-top: 10px;
border: 3px solid var(--border);
}
.sidebar {
padding: 1rem;
display: grid;
gap: 0.8rem;
align-content: start;
}
.sidebar h2 {
margin: 0;
font-size: 1.05rem;
}
.sidebar p,
.sidebar li {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.45;
}
.sidebar ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
}
.hint {
border-left: 3px solid var(--accent);
padding: 0.5rem 0.65rem;
background: rgba(127, 216, 255, 0.1);
border-radius: 6px;
color: #d5f2ff;
}
#output {
progress[hidden] {
display: none;
}
details {
margin-top: 15px;
}
summary {
cursor: pointer;
}
.feature-list {
padding-left: 24px;
}
.feature-list li {
margin-bottom: 10px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
textarea {
width: 100%;
min-height: 120px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
background: #0a0f19;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.8rem;
padding: 0.5rem;
margin-top: 10px;
padding: 10px;
border: 3px solid var(--border);
font-family: "Courier New", monospace;
font-size: 0.9rem;
resize: vertical;
background: white;
color: var(--text);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 3px solid var(--border);
font-size: 0.9rem;
color: var(--muted);
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
@media (max-width: 768px) {
body {
padding: 16px;
font-size: 16px;
}
h1 {
font-size: 2.2rem;
}
.canvas-container {
padding: 15px;
}
.canvas-shell {
min-height: 360px;
}
}

View File

@@ -8,54 +8,63 @@
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="hero">
<div class="title-group">
<h1>Thin Ice</h1>
<div class="container">
<a href="/#projects" class="back-button">← Back</a>
<header>
<h1>Thin Ice</h1>
<p class="subtitle">Inspired by Club Penguin's Thin Ice mini-game.</p>
</header>
<section>
<p>
You start on safe ice, breaking ice where you walk, trying to reach the finish tile before trapping yourself.
</p>
<div class="info-box">
<p>
A game inspired by the Club Penguin mini-game of the same name.
See if you can get a gold medal on all 6 levels by breaking every single tile of ice!
</p>
</div>
</section>
<section class="layout">
<article class="panel game-shell" aria-label="Thin Ice game canvas">
<div class="status-row">
<div class="loading">
<div class="spinner" id="spinner" aria-hidden="true"></div>
<div id="status" role="status" aria-live="polite">Downloading...</div>
</div>
<progress id="progress" max="100" value="0" hidden></progress>
</div>
<div class="canvas-container">
<div class="canvas-toolbar">
<p>Game</p>
<button id="fullscreenButton" class="action-button" type="button">Fullscreen</button>
</div>
<div class="canvas-frame">
<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
</div>
<div class="canvas-shell">
<canvas id="canvas" class="emscripten" aria-label="Thin Ice game canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
</div>
<div class="controls" id="controls">
<label><input type="checkbox" id="resize" /> Resize canvas</label>
<label><input type="checkbox" id="pointerLock" checked /> Lock/hide mouse pointer</label>
<button
class="btn"
type="button"
onclick='Module.requestFullscreen(document.getElementById("pointerLock").checked,document.getElementById("resize").checked)'>
Fullscreen
</button>
</div>
<div class="canvas-options">
<label><input type="checkbox" id="resize" /> Resize canvas in fullscreen</label>
<label><input type="checkbox" id="pointerLock" checked /> Lock pointer in fullscreen</label>
</div>
<textarea id="output" rows="8" aria-label="Runtime output"></textarea>
</article>
<div id="status" class="status">Downloading...</div>
<progress id="progress" value="0" max="100" hidden></progress>
<aside class="panel sidebar" aria-label="How to play">
<h2>How to Play</h2>
<ul>
<li>Start on the first tile and plan your route before moving.</li>
<li>Reach the end tile while avoiding water and broken ice.</li>
<li>If you slip up, reset and try a smarter path.</li>
</ul>
</aside>
<details>
<summary>Show console output</summary>
<label class="visually-hidden" for="output">Console output</label>
<textarea id="output" rows="8" readonly></textarea>
</details>
</div>
<section>
<h2>How to Play</h2>
<ul class="feature-list">
<li>Use WASM keys to move across the ice.</li>
<li>Plan ahead so you do not strand yourself on broken tiles.</li>
<li>Reach the end tile without falling into the water.</li>
</ul>
</section>
</main>
<footer>
<p>Built with C++ & Raylib, compiled to WASM with emscripten.</p>
</footer>
</div>
<script src="script.js"></script>
<script async src="thin_ice.js"></script>

19
projects/tsp/canvas.js Normal file
View File

@@ -0,0 +1,19 @@
export function setupCanvas(gl, canvas) {
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
return {
canvas,
gl,
aspectRatio: canvas.width / canvas.height
};
}
export function resizeCanvas(gl, canvas, program) {
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.uniform2f(gl.getUniformLocation(program, "u_resolution"), canvas.width, canvas.height);
}

74
projects/tsp/index.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travelling Salesman Problem</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="container">
<a href="/#projects" class="back-button">← Back</a>
<header>
<h1>Travelling Salesman Problem</h1>
<p class="subtitle">An interactive route visualiser built with WebGL and WebAssembly.</p>
</header>
<section>
<p>
After writing one of my first tutorials on the Travelling Salesman Problem, I wanted to revisit it with a slightly more technical stack.
The backend is written in Zig (compiled to WebAssembly), and the rendering is done directly in WebGL instead of p5.js or Raylib.
</p>
<p>
This project was also inspired by <a href="https://www.youtube.com/@sphaerophoria">sphaerophoria</a> who started a <a href="https://www.youtube.com/watch?v=RiLjPBci6Og&list=PL980gcR1LE3L8RoIMSNBFfw4dFfS3rrsk">series</a> on creating his own map.
</p>
<div class="info-box">
<p>
Drag points around the canvas and watch the route update in real time.
</p>
</div>
</section>
<div class="canvas-container">
<div class="canvas-toolbar">
<div class="control-group" aria-label="TSP controls">
<label for="pointCountInput">Points</label>
<input id="pointCountInput" type="number" min="5" max="100" step="1" value="36" />
<button id="applyPointCountButton" class="action-button" type="button">Regenerate</button>
</div>
</div>
<div class="canvas-shell">
<canvas id="tsp-canvas" aria-label="Travelling Salesman Problem canvas">
HTML5 canvas not supported in browser
</canvas>
</div>
<div class="status-row">
<div id="pointCountStatus" class="status">36 points</div>
<p class="canvas-note">Tip: click and drag a point to recompute the route.</p>
</div>
</div>
<section>
<h3>Technical Details</h3>
<p>
The backend is written in Zig, and the route is built in two passes: a nearest-neighbour pass followed by a 2-opt local search.
</p>
<p>
Point coordinates are stored in WebAssembly memory, and the route order is recalculated whenever the layout changes.
Rendering is handled in WebGL to keep interaction smooth as the point count increases.
</p>
</section>
<footer>
<p>Built with Zig, WebGL2 and WebAssembly</p>
</footer>
</div>
<script src="./point.js"></script>
<script type="module" src="./main.js"></script>
</body>
</html>

245
projects/tsp/main.js Normal file
View File

@@ -0,0 +1,245 @@
import { initWasm } from "./wasm.js";
import { setupCanvas, resizeCanvas } from "./canvas.js";
import { createShaderProgram } from "./shaders.js";
import { draw } from "./tsp-gl.js";
import { initPoints } from "./utils.js";
const DEFAULT_POINT_COUNT = 36;
const MIN_POINTS = 4;
const MAX_POINTS = 96;
const RADIUS_SIZE = 0.006;
function clampPointCount(value) {
if (Number.isNaN(value)) {
return DEFAULT_POINT_COUNT;
}
return Math.max(MIN_POINTS, Math.min(MAX_POINTS, value));
}
function createPointFragmentShader(pointCount) {
return `
precision mediump float;
uniform vec2 u_resolution;
uniform vec2 u_centers[${pointCount}];
void main() {
vec2 position = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
vec2 scaledPosition = vec2(position.x * aspect, position.y);
float radius = ${RADIUS_SIZE};
for (int i = 0; i < ${pointCount}; i++) {
vec2 center = u_centers[i];
vec2 scaledCenter = vec2(center.x * aspect, center.y);
float distance = length(scaledPosition - scaledCenter) * (1.0 / aspect);
if (distance <= radius) {
gl_FragColor = vec4(0.5, center, 1.0);
return;
}
}
gl_FragColor = vec4(0.1, 0.1, 0.1, 1.0);
}
`;
}
function createLineFragmentShader() {
return `
precision mediump float;
void main(void) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
`;
}
async function run() {
const canvas = document.getElementById("tsp-canvas");
const pointCountInput = document.getElementById("pointCountInput");
const applyPointCountButton = document.getElementById("applyPointCountButton");
const pointCountStatus = document.getElementById("pointCountStatus");
if (!canvas || !pointCountInput || !applyPointCountButton || !pointCountStatus) {
return;
}
const gl = canvas.getContext("webgl2");
if (!gl) {
pointCountStatus.textContent = "WebGL2 unavailable in this browser.";
return;
}
gl.clearColor(0.1, 0.1, 0.1, 1);
const {
initialisePoints,
getPointOrder,
memory
} = await initWasm();
const vertexCode = `
attribute vec2 a_position;
void main(void) {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
setupCanvas(gl, canvas);
const positionBuffer = gl.createBuffer();
const lineBuffer = gl.createBuffer();
if (!positionBuffer || !lineBuffer) {
pointCountStatus.textContent = "Unable to initialise WebGL buffers.";
return;
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 3, -1, -1, 3];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const state = {
scene: null,
draggingIndex: -1
};
function updateStatus(pointCount) {
pointCountStatus.textContent = `${pointCount} point${pointCount === 1 ? "" : "s"}`;
}
function recalculateRoute(scene) {
const pointOrderPtr = getPointOrder(scene.pointsPtr, scene.pointCount);
scene.pointOrder = Array.from(new Uint32Array(memory.buffer, pointOrderPtr, scene.pointCount));
}
function syncPointsToMemory(scene) {
scene.pointArray = new Float32Array(memory.buffer, scene.pointsPtr, scene.pointCount * 2);
for (let i = 0; i < scene.points.length; i++) {
scene.pointArray[i * 2] = scene.points[i].cx;
scene.pointArray[i * 2 + 1] = scene.points[i].cy;
}
}
function initialiseScene(rawPointCount) {
const pointCount = clampPointCount(rawPointCount);
if (state.scene) {
gl.deleteProgram(state.scene.program);
gl.deleteProgram(state.scene.lineProgram);
}
const pointsPtr = initialisePoints(pointCount, Date.now());
const pointArray = new Float32Array(memory.buffer, pointsPtr, pointCount * 2);
const points = initPoints(pointArray);
const program = createShaderProgram(gl, vertexCode, createPointFragmentShader(pointCount));
const lineProgram = createShaderProgram(gl, vertexCode, createLineFragmentShader());
state.scene = {
pointCount,
pointsPtr,
pointArray,
points,
pointOrder: [],
program,
lineProgram
};
recalculateRoute(state.scene);
resizeCanvas(gl, canvas, program);
updateStatus(pointCount);
pointCountInput.value = String(pointCount);
}
canvas.addEventListener("mousedown", (e) => {
if (!state.scene) {
return;
}
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = 1 - ((e.clientY - rect.top) / rect.height) * 2;
for (let i = 0; i < state.scene.points.length; i++) {
const point = state.scene.points[i];
const dx = (0.5 * x + 0.5) - point.cx;
const dy = (0.5 * y + 0.5) - point.cy;
const distance = Math.hypot(dx, dy);
if (distance <= RADIUS_SIZE) {
state.draggingIndex = i;
break;
}
}
});
canvas.addEventListener("mousemove", (e) => {
if (!state.scene || state.draggingIndex === -1) {
return;
}
const rect = canvas.getBoundingClientRect();
state.scene.points[state.draggingIndex].cx = (e.clientX - rect.left) / rect.width;
state.scene.points[state.draggingIndex].cy = 1 - (e.clientY - rect.top) / rect.height;
syncPointsToMemory(state.scene);
recalculateRoute(state.scene);
});
function stopDragging() {
state.draggingIndex = -1;
}
canvas.addEventListener("mouseup", stopDragging);
canvas.addEventListener("mouseleave", stopDragging);
function applyPointCount() {
const nextPointCount = clampPointCount(Number.parseInt(pointCountInput.value, 10));
initialiseScene(nextPointCount);
}
applyPointCountButton.addEventListener("click", applyPointCount);
pointCountInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
applyPointCount();
}
});
pointCountInput.addEventListener("blur", () => {
pointCountInput.value = String(clampPointCount(Number.parseInt(pointCountInput.value, 10)));
});
window.addEventListener("resize", () => {
if (!state.scene) {
return;
}
resizeCanvas(gl, canvas, state.scene.program);
});
initialiseScene(DEFAULT_POINT_COUNT);
function render() {
if (state.scene) {
draw(
gl,
state.scene.points,
state.scene.program,
state.scene.lineProgram,
positionBuffer,
lineBuffer,
state.scene.pointOrder,
1
);
}
requestAnimationFrame(render);
}
render();
}
await run();

BIN
projects/tsp/main.wasm Executable file

Binary file not shown.

6
projects/tsp/point.js Normal file
View File

@@ -0,0 +1,6 @@
class Point {
constructor(x, y) {
this.cx = x;
this.cy = y;
}
}

20
projects/tsp/shaders.js Normal file
View File

@@ -0,0 +1,20 @@
// shaders.js
export function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
export function createShaderProgram(gl, vertexCode, fragCode) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexCode);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragCode);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
return program;
}

202
projects/tsp/style.css Normal file
View File

@@ -0,0 +1,202 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: white;
color: black;
font-family: Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #ccc;
padding-bottom: 20px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
h2 {
font-size: 1.8rem;
margin: 20px 0 15px;
}
h3 {
font-size: 1.3rem;
margin: 15px 0;
}
p {
margin-bottom: 15px;
color: #333;
}
.subtitle {
color: #666;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 15px;
background: #f0f0f0;
color: black;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 4px;
}
.back-button:hover,
.action-button:hover {
background: #e0e0e0;
}
.info-box {
background: #f9f9f9;
border-left: 4px solid #333;
padding: 15px;
margin: 20px 0;
}
.canvas-container {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
margin: 30px 0;
}
.canvas-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.canvas-toolbar p {
margin: 0;
color: #666;
font-size: 0.95rem;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.control-group label {
color: #555;
font-size: 0.95rem;
}
.control-group input {
width: 90px;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font: inherit;
background: white;
color: black;
}
.action-button {
padding: 8px 12px;
background: #f0f0f0;
color: black;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.canvas-shell {
display: flex;
justify-content: center;
align-items: center;
min-height: 500px;
overflow: auto;
background: white;
border: 1px solid #ddd;
}
#tsp-canvas {
display: block;
margin: 0 auto;
width: 100%;
height: 560px;
max-width: 100%;
background: black;
outline: none;
image-rendering: crisp-edges;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 15px;
}
.status {
min-height: 24px;
color: #666;
margin: 0;
}
.canvas-note {
margin: 0;
color: #666;
font-size: 0.95rem;
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ccc;
font-size: 0.85rem;
color: #666;
}
@media (max-width: 768px) {
body {
padding: 16px;
}
h1 {
font-size: 2rem;
}
.canvas-container {
padding: 15px;
}
.canvas-shell {
min-height: 360px;
}
#tsp-canvas {
height: 400px;
}
}

46
projects/tsp/tsp-gl.js Normal file
View File

@@ -0,0 +1,46 @@
// tsp-gl.js
import { updateLines } from "./utils.js";
export function draw(gl, points, program, lineProgram, positionBuffer, lineBuffer, pointOrder, aspectRatio) {
gl.clear(gl.COLOR_BUFFER_BIT);
// Draw Circles
updateCircleUniforms(gl, points, program, positionBuffer);
// Draw Lines
const lineVertices = updateLines(points, pointOrder);
drawLines(gl, lineProgram, lineBuffer, lineVertices);
}
function updateCircleUniforms(gl, points, program, positionBuffer) {
const centerArray = new Float32Array(points.length * 2);
points.forEach((point, i) => {
centerArray[i * 2] = point.cx;
centerArray[i * 2 + 1] = point.cy;
});
gl.useProgram(program);
gl.uniform2fv(gl.getUniformLocation(program, "u_centers"), centerArray);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positionAttribLoc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttribLoc);
gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
}
function drawLines(gl, program, buffer, lineVertices) {
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, lineVertices, gl.DYNAMIC_DRAW);
const positionAttribLoc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttribLoc);
gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.LINES, 0, lineVertices.length / 2);
}

19
projects/tsp/utils.js Normal file
View File

@@ -0,0 +1,19 @@
// utils.js
export function initPoints(points) {
let cs = [];
for (let i = 0; i < points.length; i += 2) {
cs.push(new Point(points[i], points[i + 1]));
}
return cs;
}
export function updateLines(points, pointOrder) {
const lineVertices = [];
for (let i = 0; i < pointOrder.length - 1; i++) {
const j = pointOrder[i];
const k = pointOrder[i + 1];
lineVertices.push(points[j].cx * 2 - 1, points[j].cy * 2 - 1);
lineVertices.push(points[k].cx * 2 - 1, points[k].cy * 2 - 1);
}
return new Float32Array(lineVertices);
}

24
projects/tsp/wasm.js Normal file
View File

@@ -0,0 +1,24 @@
export async function initWasm() {
let wasmMemory = new WebAssembly.Memory({
initial: 256,
maximum: 256
});
let importObject = {
env: {
memory: wasmMemory,
}
};
const response = await fetch("./main.wasm");
const wasmModule = await WebAssembly.instantiateStreaming(response, importObject);
const { initialisePoints, getPointOrder, memory } = wasmModule.instance.exports;
return {
initialisePoints,
getPointOrder,
memory,
wasmMemory
}
}