Physics Playground
Qwen 3.7 Flash · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 70,879 bytes · SHA-256 d056217d97f0
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Physics Playground</title>
<style>
/* ── css: layout ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
overflow: hidden;
background: #0a0a1a;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
color: #c0c0e0;
}
#canvas-container {
width: 100%;
height: calc(100vh - 60px - 72px);
display: flex;
justify-content: center;
align-items: center;
background: #1a1a2e;
position: relative;
overflow: hidden;
}
#world-canvas {
display: block;
image-rendering: auto;
cursor: crosshair;
}
#toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50px;
background: rgba(16, 16, 36, 0.95);
border-bottom: 1px solid #2a2a4e;
display: flex;
align-items: center;
padding: 0 14px;
gap: 6px;
z-index: 10;
flex-wrap: nowrap;
overflow-x: auto;
backdrop-filter: blur(4px);
}
#tb-label {
color: #8888bb;
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
flex-shrink: 0;
margin-right: 4px;
}
.tool-btn {
background: #252545;
color: #a0a0c0;
border: 1px solid #353555;
border-radius: 5px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
}
.tool-btn:hover {
background: #353560;
border-color: #5555aa;
color: #d0d0f0;
}
.tool-btn.active {
background: #3a3a6a;
border-color: #7777cc;
color: #ffffff;
box-shadow: 0 0 8px rgba(100, 100, 200, 0.3);
}
.icon-btn {
background: #252545;
color: #a0a0c0;
border: 1px solid #353555;
border-radius: 5px;
width: 32px;
height: 30px;
font-size: 15px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.icon-btn:hover {
background: #353560;
color: #ffffff;
}
#toolbar label {
color: #7777aa;
font-size: 11px;
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
white-space: nowrap;
}
#toolbar label span {
color: #8888bb;
font-weight: 500;
min-width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
#toolbar input[type="range"] {
width: 64px;
height: 4px;
accent-color: #6666bb;
}
#toolbar select {
background: #252545;
color: #a0a0c0;
border: 1px solid #353555;
border-radius: 3px;
padding: 2px 4px;
font-size: 11px;
cursor: pointer;
}
#hud-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 36px;
background: rgba(16, 16, 36, 0.85);
border-top: 1px solid #2a2a4e;
display: flex;
align-items: center;
padding: 0 14px;
z-index: 10;
gap: 16px;
}
#hud-bar span {
font-size: 11px;
color: #6666aa;
font-family: 'SF Mono', 'Consolas', monospace;
flex-shrink: 0;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./physics":"src/physics.ts","./render":"src/render.ts","./input":"src/input.ts","./spawner":"src/spawner.ts","./types":"src/types.ts"},"src/vec2.ts":{"./types":"src/types.ts"},"src/physics.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts"},"src/spawner.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts","./physics":"src/physics.ts"},"src/input.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts","./physics":"src/physics.ts","./spawner":"src/spawner.ts"},"src/render.ts":{"./config":"src/config.ts","./physics":"src/physics.ts","./types":"src/types.ts","./input":"src/input.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Main — entry point: game loop, UI wiring, demo scene
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("./config");
const physics_1 = require("./physics");
const render_1 = require("./render");
const input_1 = require("./input");
const spawner_1 = require("./spawner");
// ─── DOM Setup ─────────────────────────────────────────────
const app = document.getElementById('app');
app.innerHTML = `
<div id="canvas-container">
<canvas id="world-canvas"></canvas>
</div>
<div id="toolbar">
<span id="tb-label">TOOL:</span>
<button id="btn-rope" class="tool-btn" data-tool="rope">Rope</button>
<button id="btn-cloth" class="tool-btn" data-tool="cloth">Cloth</button>
<button id="btn-softbody" class="tool-btn" data-tool="softbody">Blob</button>
<button id="btn-obs" class="tool-btn" data-tool="obs">Obstacle</button>
<button id="btn-reset" class="icon-btn" title="Reset">↺</button>
<button id="btn-stress" class="icon-btn" title="Toggle Stress Viz">◉</button>
<label id="lbl-grav">
<span>G:</span>
<input type="range" id="slider-grav" min="0" max="2000" value="980" step="10">
<span id="val-grav" class="val">980</span>
</label>
<label id="lbl-iter">
<span>I:</span>
<input type="range" id="slider-iter" min="1" max="16" value="6" step="1">
<span id="val-iter" class="val">6</span>
</label>
<label id="lbl-phi">
Pin:
<select id="sel-pattern">
<option value="top">Top</option>
<option value="corners">Corners</option>
<option value="full">Full</option>
</select>
</label>
</div>
<div id="hud-bar">
<span id="hud-info"></span>
</div>
`;
// ─── Canvas & Context ──────────────────────────────────────
const canvas = document.getElementById('world-canvas');
const container = document.getElementById('canvas-container');
// Set canvas size based on window
function resizeCanvas() {
const toolbarH = 54;
const hudH = 190; // toolbar + HUD
const w = window.innerWidth;
const h = Math.max(window.innerHeight - toolbarH - hudH, 400);
canvas.width = w;
canvas.height = h;
// Set actual pixel dimensions (for retina)
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
// Reset world dimensions
sim.world.canvasW = canvas.width;
sim.world.canvasH = canvas.height;
sim.input.canvasW = canvas.width;
sim.input.canvasH = canvas.height;
}
// ─── Simulation State ──────────────────────────────────────
const sim = {
world: (0, physics_1.createWorld)(1280, 720),
canvas,
renderState: null,
input: (0, input_1.createInputState)(1280, 720),
accumulator: 0,
lastTime: 0,
selectedTool: 'rope',
placingObstacle: false,
obsStart: null,
};
// ─── Event Handlers ────────────────────────────────────────
let mouseDown = false;
let mouseButton = 0;
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
mouseDown = true;
mouseButton = e.button;
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
sim.input.mouseX = mx;
sim.input.mouseY = my;
sim.input.lastMouseX = mx;
sim.input.lastMouseY = my;
sim.input.prevMouseX = mx;
sim.input.prevMouseY = my;
if (sim.selectedTool === 'rope') {
(0, spawner_1.beginDragCreateRope)(mx, my);
(0, render_1.setDragRopePreview)(mx, my);
}
else if (sim.selectedTool === 'cloth') {
(0, spawner_1.beginDragCreateCloth)(mx, my);
}
else if (sim.selectedTool === 'obs') {
sim.placingObstacle = true;
sim.obsStart = { x: mx, y: my };
}
else {
(0, input_1.handleMousedown)(sim.input, sim.world, e.button, e.ctrlKey || e.metaKey);
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
sim.input.mouseX = mx;
sim.input.mouseY = my;
if (sim.selectedTool === 'rope' && (0, render_1.isDraggingRope)()) {
(0, render_1.updateDragRopePreview)(mx, my);
// update drag end position
}
(0, input_1.handleMousemove)(sim.input, sim.world);
});
canvas.addEventListener('mouseup', (e) => {
mouseDown = false;
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
sim.input.mouseX = mx;
sim.input.mouseY = my;
if (sim.selectedTool === 'rope') {
(0, spawner_1.endDragCreateRope)(sim.world, mx, my);
(0, render_1.clearDragRopePreview)();
}
else if (sim.selectedTool === 'cloth') {
const pattern = document.getElementById('sel-pattern');
(0, spawner_1.endDragCreateCloth)(sim.world, mx, my, pattern.value);
}
else if (sim.selectedTool === 'obs' && sim.obsStart) {
// Create a circle obstacle at click, or a rect if dragging
const dx = mx - sim.obsStart.x;
const dy = my - sim.obsStart.y;
const dist = Math.hypot(dx, dy);
if (dist > 15) {
// Circle centered at mid-point with radius = dist/2
const cx = sim.obsStart.x + dx / 2;
const cy = sim.obsStart.y + dy / 2;
const r = dist / 2;
sim.world.obstacles.push({ type: 'circle', x: cx, y: cy, r });
}
else {
// Small circle
sim.world.obstacles.push({ type: 'circle', x: sim.obsStart.x, y: sim.obsStart.y, r: 25 });
}
sim.obsStart = null;
}
(0, input_1.handleMouseup)(sim.input, sim.world, e.button);
});
// Prevent context menu
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (touch.clientX - rect.left) * scale;
const my = (touch.clientY - rect.top) * scale;
sim.input.mouseX = mx;
sim.input.mouseY = my;
sim.input.lastMouseX = mx;
sim.input.lastMouseY = my;
sim.input.prevMouseX = mx;
sim.input.prevMouseY = my;
mouseDown = true;
mouseButton = 0;
(0, input_1.handleMousedown)(sim.input, sim.world, 0);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (touch.clientX - rect.left) * scale;
const my = (touch.clientY - rect.top) * scale;
sim.input.mouseX = mx;
sim.input.mouseY = my;
(0, input_1.handleMousemove)(sim.input, sim.world);
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
mouseDown = false;
const rect = canvas.getBoundingClientRect();
const scale = canvas.width / rect.width;
const mx = (sim.input.mouseX);
const my = (sim.input.mouseY);
(0, input_1.handleMouseup)(sim.input, sim.world, 0);
}, { passive: false });
// ─── Keyboard ──────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
return;
const action = (0, input_1.handleKeydown)(e.key);
if (action === 'pause') {
e.preventDefault();
sim.world.paused = !sim.world.paused;
}
else if (action === 'step') {
e.preventDefault();
if (sim.world.paused) {
simWorldOnce(config_1.PHYSICS.TAU);
}
}
else if (action === 'gravity-toggle') {
e.preventDefault();
sim.world.gravityOn = !sim.world.gravityOn;
config_1.PHYSICS.GRAVITY_ON = sim.world.gravityOn;
const slider = document.getElementById('slider-grav');
slider.value = sim.world.gravityOn ? '980' : '0';
document.getElementById('val-grav').textContent = slider.value;
}
else if (action === 'wind-toggle') {
// Toggle wind off/on
const wind = window.wind || 0;
window.wind = wind === 0 ? 200 : 0;
sim.world.wind = window.wind;
}
// Tool shortcuts
const tools = document.querySelectorAll('.tool-btn');
const keys = { 'r': 0, 'l': 1, 'b': 2 };
const keyMap = { 'r': 0, 'l': 1, 'b': 2 };
if (e.key in keyMap) {
const idx = keyMap[e.key.toLowerCase()];
tools[idx]?.dispatchEvent(new Event('click'));
}
});
// ─── UI Controls ───────────────────────────────────────────
// Toolbar: tool selection
document.querySelectorAll('.tool-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tool = btn.getAttribute('data-tool');
sim.selectedTool = tool;
});
});
// Make the first tool active by default
document.querySelector('#btn-rope')?.classList.add('active');
// Reset
document.getElementById('btn-reset')?.addEventListener('click', () => {
sim.world.obstacles.length = 0;
sim.world.points.length = 0;
sim.world.constraints.length = 0;
// Re-add default obstacles if any
addDefaultObstacles();
});
// Stress viz toggle
let showStress = false;
document.getElementById('btn-stress')?.addEventListener('click', () => {
showStress = !showStress;
if (showStress) {
document.getElementById('btn-stress')?.setAttribute('style', 'color:#ff3333');
}
else {
document.getElementById('btn-stress')?.setAttribute('style', '');
}
});
// Gravity slider
const gravSlider = document.getElementById('slider-grav');
const gravVal = document.getElementById('val-grav');
gravSlider?.addEventListener('input', () => {
const v = parseFloat(gravSlider.value);
gravVal.textContent = gravSlider.value;
sim.world.gravityOn = v > 0;
if (v === 0)
sim.world.gravityOn = false;
config_1.PHYSICS.GRAVITY = v;
});
// Iterations slider
const iterSlider = document.getElementById('slider-iter');
const iterVal = document.getElementById('val-iter');
iterSlider?.addEventListener('input', () => {
iterVal.textContent = iterSlider.value;
// We'll read the current value during each step
});
// ─── Demo Scene ─────────────────────────────────────────────
function addDefaultObstacles() {
const W = sim.world.canvasW;
const H = sim.world.canvasH;
// Circle obstacle on the floor
sim.world.obstacles.push({ type: 'circle', x: W * 0.7, y: H - 60, r: 40 });
sim.world.obstacles.push({ type: 'circle', x: W * 0.3, y: H - 40, r: 25 });
// A small platform
sim.world.obstacles.push({ type: 'rect', x: W * 0.45, y: H * 0.55, w: 180, h: 15 });
}
function createDemoScene() {
const W = sim.world.canvasW;
const H = sim.world.canvasH;
// A long rope from top-left
(0, spawner_1.spawnRope)(sim.world, W * 0.15, 50, W * 0.25, H * 0.4);
// A cloth pinned at two top corners
(0, spawner_1.spawnCloth)(sim.world, W * 0.45, 60, 10, 14, 14, 'corners');
// A soft body blob
(0, spawner_1.spawnSoftBody)(sim.world, W * 0.7, 120, 50, 8);
// A ball
(0, spawner_1.spawnBall)(sim.world, W * 0.85, 80, 20);
}
// Re-initialize demo after resize
function recreateDemo() {
sim.world.obstacles.length = 0;
sim.world.points.length = 0;
sim.world.constraints.length = 0;
addDefaultObstacles();
createDemoScene();
}
// ─── Physics Step (single) ────────────────────────────────
function simWorldOnce(dt) {
const iter = parseInt(iterSlider.value) || config_1.PHYSICS.ITERATIONS;
const t0 = performance.now();
sim.world.gravityOn = config_1.PHYSICS.GRAVITY_ON;
sim.world.wind = window.wind || 0;
(0, physics_2.stepWorld)(sim.world, dt, iter);
const t1 = performance.now();
if (sim.renderState) {
sim.renderState.lastStepTime = (t1 - t0);
}
}
// Re-import stepWorld from physics
const physics_2 = require("./physics");
// ─── Game Loop ─────────────────────────────────────────────
function gameLoop(timestamp) {
if (!sim.lastTime)
sim.lastTime = timestamp;
let frameDelta = (timestamp - sim.lastTime) / 1000;
sim.lastTime = timestamp;
// Clamp to prevent explosion after tab switch
if (frameDelta > config_1.PHYSICS.MAX_FRAME_DELTA) {
frameDelta = config_1.PHYSICS.MAX_FRAME_DELTA;
}
// If paused, don't accumulate
if (!sim.world.paused) {
sim.accumulator += frameDelta;
// Process fixed-step frames
const maxSteps = 5; // safety limit
let steps = 0;
while (sim.accumulator >= config_1.PHYSICS.TAU && steps < maxSteps) {
simWorldOnce(config_1.PHYSICS.TAU);
sim.accumulator -= config_1.PHYSICS.TAU;
steps++;
}
// Remaining accumulator is fine — it will be used next frame
if (sim.accumulator >= config_1.PHYSICS.TAU) {
// Too much accumulated — discard to prevent spiral
sim.accumulator = 0;
}
}
// Render
if (sim.renderState) {
(0, render_1.render)(sim.world, sim.renderState, sim.input);
}
// Draw obstacle preview while placing
if (sim.placingObstacle && sim.obsStart) {
const ctx = sim.renderState.ctx;
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.beginPath();
const dx = sim.input.mouseX - sim.obsStart.x;
const dy = sim.input.mouseY - sim.obsStart.y;
const cx = sim.obsStart.x + dx / 2;
const cy = sim.obsStart.y + dy / 2;
const r = Math.hypot(dx, dy) / 2;
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
requestAnimationFrame(gameLoop);
}
// ─── Window Resize ──────────────────────────────────────────
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
resizeCanvas();
simulateResize();
}, 150);
});
function simulateResize() {
// Context persists across resize; just update stored dimensions
sim.renderState.canvasW = sim.world.canvasW;
sim.renderState.canvasH = sim.world.canvasH;
}
// ─── Init ──────────────────────────────────────────────────
function start() {
resizeCanvas();
addDefaultObstacles();
createDemoScene();
const ctx = canvas.getContext('2d');
sim.renderState = (0, render_1.createRenderState)(ctx, sim.world.canvasW, sim.world.canvasH);
requestAnimationFrame(gameLoop);
}
// Expose sim on window for probing
window.sim = sim;
start();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Tunable Constants — tweak these to change the feel of the sim
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.COLORS = exports.SPAWN = exports.RENDER = exports.PHYSICS = void 0;
exports.PHYSICS = {
/** Fixed physics timestep (s) — 60 Hz → ~0.01667 */
TAU: 1 / 60,
/** Max accumulator clamp (s) — prevents explosion after tab switch */
MAX_FRAME_DELTA: 0.25,
/** Constraint solver iterations per physics step */
ITERATIONS: 6,
/** Verlet damping (0 = no friction, 1 = fully overdamped) */
DAMPING: 0.995,
/** Gravity strength (px/s²) */
GRAVITY: 980,
/** Enable gravity */
GRAVITY_ON: true,
/** Wind strength (px/s²) in X direction */
WIND: 0,
/** Collision friction per axis */
FRICTION: 0.7,
/** Floor bounce restitution */
BOUNCE: 0.4,
/** Minimum safe distance to avoid division-by-zero in constraints */
MIN_DIST: 0.001,
};
exports.RENDER = {
/** Point radius for dots */
DOT_RADIUS: 3,
/** Constraint line width */
LINE_WIDTH: 1.5,
/** Cloth fill opacity */
CLOTH_OPACITY: 0.35,
/** Soft body fill opacity */
SOFT_BODY_OPACITY: 0.7,
/** Ball fill opacity */
BALL_OPACITY: 0.9,
/** Grab highlight radius */
GRAB_RADIUS: 20,
/** Tear radius (px) */
TEAR_RADIUS: 18,
/** Stress map: below this ratio → green, above → red */
STRESS_THRESH: 0.15,
/** Max stress ratio for full-red → 100% stretch means double length */
STRESS_MAX: 0.5,
};
exports.SPAWN = {
/** Rope segment length */
ROPE_SEG_LEN: 12,
/** Rope point count per drag pixel of length */
ROPE_MIN_POINTS: 8,
/** Cloth grid rows / cols */
CLOTH_ROWS: 10,
CLOTH_COLS: 8,
/** Cloth spacing */
CLOTH_SPACING: 14,
/** Soft body radius in points */
SOFT_BODY_R: 5,
/** Soft body brace depth (1 = only neighbors, 2 = next-nearest) */
SOFT_BODY_BRACE_DEPTH: 1,
};
exports.COLORS = {
/** Background */
BG: '#1a1a2e',
/** Grid lines */
GRID: '#252545',
/** Static walls/floor */
WALL: '#404068',
/** HUD text */
HUD_TEXT: '#c0c0e0',
/** Rope color */
ROPE: '#ff6b6b',
/** Cloth color */
CLOTH: '#4ecdc4',
/** Soft body (blob) color */
SOFT_BODY: '#ffe66d',
/** Ball color */
BALL: '#a78bfa',
/** Pin indicator */
PIN: '#ffffff',
/** Grab highlight */
GRAB: '#ffff00',
/** Tear indicator */
TEAR: '#ff004488',
/** Stressed constraint red */
STRESS_RED: '#ff3333',
/** Unstressed constraint green */
STRESS_GREEN: '#33ff66',
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Types — core data structures for the physics engine
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/vec2.ts ──
__mods["src/vec2.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Vec2 — lightweight 2D vector math (no allocation helpers)
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.vec2 = vec2;
exports.v2Add = v2Add;
exports.v2Sub = v2Sub;
exports.v2Scale = v2Scale;
exports.v2Neg = v2Neg;
exports.v2Len = v2Len;
exports.v2LenSq = v2LenSq;
exports.v2Norm = v2Norm;
exports.v2Dot = v2Dot;
exports.v2Dist = v2Dist;
exports.v2Min = v2Min;
exports.v2Max = v2Max;
exports.v2Copy = v2Copy;
exports.v2AddScaled = v2AddScaled;
function vec2(x = 0, y = 0) { return { x, y }; }
function v2Add(a, b) { return { x: a.x + b.x, y: a.y + b.y }; }
function v2Sub(a, b) { return { x: a.x - b.x, y: a.y - b.y }; }
function v2Scale(v, s) { return { x: v.x * s, y: v.y * s }; }
function v2Neg(v) { return { x: -v.x, y: -v.y }; }
function v2Len(v) { return Math.hypot(v.x, v.y); }
function v2LenSq(v) { return v.x * v.x + v.y * v.y; }
/** Normalised, returns zero-length vector if empty */
function v2Norm(v) {
const len = v2Len(v);
if (len < 1e-9)
return vec2(0, 0);
return { x: v.x / len, y: v.y / len };
}
function v2Dot(a, b) { return a.x * b.x + a.y * b.y; }
function v2Dist(a, b) { return v2Len(v2Sub(a, b)); }
function v2Min(a, b) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }; }
function v2Max(a, b) { return { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }; }
/** Copy */
function v2Copy(v) { return { x: v.x, y: v.y }; }
/** Mutate v = a + b * s */
function v2AddScaled(v, a, s) {
v.x += a.x * s;
v.y += a.y * s;
}
};
// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Physics — Verlet integration + constraint relaxation + collision
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createWorld = createWorld;
exports.resetWorld = resetWorld;
exports.stepWorld = stepWorld;
exports.getConstraintStress = getConstraintStress;
exports.addRectObstacle = addRectObstacle;
exports.addCircleObstacle = addCircleObstacle;
const config_1 = require("./config");
/** Create an empty world */
function createWorld(cw, ch) {
return {
points: [],
constraints: [],
obstacles: [],
canvasW: cw,
canvasH: ch,
paused: false,
gravityOn: true,
wind: 0,
};
}
/** Reset the world — remove all points and constraints */
function resetWorld(world) {
world.points.length = 0;
world.constraints.length = 0;
world.paused = false;
world.gravityOn = true;
world.wind = 0;
}
/** Apply gravity and wind to all points */
function applyForces(world, dt) {
const g = config_1.PHYSICS.GRAVITY;
for (let i = 0; i < world.points.length; i++) {
const p = world.points[i];
if (p.pin)
continue;
// gravity (always in Y+)
if (world.gravityOn && g > 0) {
p.force.x = 0;
p.force.y = g;
// wind
if (world.wind !== 0) {
p.force.x = world.wind;
}
}
else {
p.force.x = 0;
p.force.y = 0;
}
}
}
/** Integrate Verlet step for all unconstrained points */
function integrate(world, dt) {
const dtSq = dt * dt;
const DAMP = config_1.PHYSICS.DAMPING;
for (let i = 0; i < world.points.length; i++) {
const p = world.points[i];
if (p.pin)
continue;
const vx = (p.pos.x - p.prev.x) * DAMP;
const vy = (p.pos.y - p.prev.y) * DAMP;
p.prev.x = p.pos.x;
p.prev.y = p.pos.y;
p.pos.x += vx + p.force.x * dtSq;
p.pos.y += vy + p.force.y * dtSq;
// compute velocity from positions (for display & tear detection)
p.vx = p.pos.x - p.prev.x;
p.vy = p.pos.y - p.prev.y;
}
}
/** Solve distance constraints via relaxation */
function solveConstraints(world, iterations) {
const minDist = config_1.PHYSICS.MIN_DIST;
const pts = world.points;
const cons = world.constraints;
for (let iter = 0; iter < iterations; iter++) {
for (let ci = 0; ci < cons.length; ci++) {
const c = cons[ci];
if (!c.active || c.strength <= 0)
continue;
const a = pts[c.i];
const b = pts[c.j];
const dx = b.pos.x - a.pos.x;
const dy = b.pos.y - a.pos.y;
const distSq = dx * dx + dy * dy;
const dist = Math.sqrt(distSq);
if (dist < minDist)
continue; // skip zero-length
const diff = (c.restLength - dist) / dist;
const halfDiff = diff * 0.5;
const mx = dx * halfDiff * c.strength;
const my = dy * halfDiff * c.strength;
if (!a.pin) {
a.pos.x -= mx;
a.pos.y -= my;
}
if (!b.pin) {
b.pos.x += mx;
b.pos.y += my;
}
}
}
}
/** Boundary collisions — walls, floor, ceiling */
function solveBoundaries(world) {
const pts = world.points;
const W = world.canvasW;
const H = world.canvasH;
const FRIC = config_1.PHYSICS.FRICTION;
const BOUNCE = config_1.PHYSICS.BOUNCE;
const minR = 1;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.pin)
continue;
const r = p.radius > minR ? p.radius : minR;
// Floor
if (p.pos.y + r > H) {
p.pos.y = H - r;
// recover velocity from prev
const vy = p.pos.y - p.prev.y;
p.prev.y = p.pos.y + vy * BOUNCE;
// horizontal friction
const vx = p.pos.x - p.prev.x;
p.prev.x = p.pos.x - vx * FRIC;
}
// Ceiling
if (p.pos.y - r < 0) {
p.pos.y = r;
const vy = p.pos.y - p.prev.y;
p.prev.y = p.pos.y + vy * BOUNCE;
}
// Left wall
if (p.pos.x - r < 0) {
p.pos.x = r;
const vx = p.pos.x - p.prev.x;
p.prev.x = p.pos.x + vx * BOUNCE;
}
// Right wall
if (p.pos.x + r > W) {
p.pos.x = W - r;
const vx = p.pos.x - p.prev.x;
p.prev.x = p.pos.x + vx * BOUNCE;
}
}
}
/** Obstacle collisions — circles & rects */
function solveObstacles(world) {
const pts = world.points;
const FRIC = config_1.PHYSICS.FRICTION;
const BOUNCE = config_1.PHYSICS.BOUNCE;
const minR = 1;
for (let oi = 0; oi < world.obstacles.length; oi++) {
const obs = world.obstacles[oi];
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.pin)
continue;
const r = p.radius > minR ? p.radius : minR;
if (obs.type === 'circle') {
const c = obs.r;
const dx = p.pos.x - obs.x;
const dy = p.pos.y - obs.y;
const dist = Math.hypot(dx, dy);
const minD = r + c;
if (dist < minD && dist > 1e-9) {
// push out
const nx = dx / dist;
const ny = dy / dist;
p.pos.x = obs.x + nx * minD;
p.pos.y = obs.y + ny * minD;
// impulse response
const velX = p.pos.x - p.prev.x;
const velY = p.pos.y - p.prev.y;
// reflect tangent
const dot = velX * nx + velY * ny;
if (dot > 0) { // moving outward, don't reflect
p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE;
p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE;
}
else {
// friction on tangent component
p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE * FRIC + nx * velX * (1 - FRIC);
p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE * FRIC + ny * velY * (1 - FRIC);
}
}
}
else if (obs.type === 'rect') {
const w = obs.w;
const h = obs.h;
// AABB check — push out if overlapping
const left = obs.x;
const right = obs.x + w;
const top = obs.y;
const bottom = obs.y + h;
// Find closest point on rect to circle center
const cx = Math.max(left, Math.min(right, p.pos.x));
const cy = Math.max(top, Math.min(bottom, p.pos.y));
const dx = p.pos.x - cx;
const dy = p.pos.y - cy;
const dist = Math.hypot(dx, dy);
if (dist < r && dist > 1e-9) {
const nx = dx / dist;
const ny = dy / dist;
p.pos.x = cx + nx * r;
p.pos.y = cy + ny * r;
const velX = p.pos.x - p.prev.x;
const velY = p.pos.y - p.prev.y;
const dot = velX * nx + velY * ny;
if (dot > 0) {
p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE;
p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE;
}
else {
p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE * FRIC + nx * velX * (1 - FRIC);
p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE * FRIC + ny * velY * (1 - FRIC);
}
}
}
}
}
}
/** Step the entire world by one fixed timestep */
function stepWorld(world, dt, iterations) {
const iter = iterations ?? config_1.PHYSICS.ITERATIONS;
applyForces(world, dt);
integrate(world, dt);
solveConstraints(world, iter);
solveObstacles(world);
solveBoundaries(world);
// Reset per-frame force accumulator
for (let i = 0; i < world.points.length; i++) {
world.points[i].force.x = 0;
world.points[i].force.y = 0;
}
}
/** Get stress ratio for a constraint (0 = rest length, 1 = double rest length) */
function getConstraintStress(c, pts) {
const ptA = pts[c.i];
const ptB = pts[c.j];
const dx = ptB.pos.x - ptA.pos.x;
const dy = ptB.pos.y - ptA.pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist === 0)
return 0;
return Math.min(Math.abs(dist - c.restLength) / c.restLength, 10);
}
/** Add a static rectangle obstacle */
function addRectObstacle(world, x, y, w, h) {
world.obstacles.push({ type: 'rect', x, y, w, h });
}
/** Add a static circle obstacle */
function addCircleObstacle(world, x, y, r) {
world.obstacles.push({ type: 'circle', x, y, r });
}
};
// ── module: src/spawner.ts ──
__mods["src/spawner.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Spawner — create ropes, cloth, soft bodies, balls
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
exports.beginDragCreateRope = beginDragCreateRope;
exports.endDragCreateRope = endDragCreateRope;
exports.cancelDragCreate = cancelDragCreate;
exports.isDragCreating = isDragCreating;
exports.beginDragCreateCloth = beginDragCreateCloth;
exports.endDragCreateCloth = endDragCreateCloth;
exports.cancelDragCreateCloth = cancelDragCreateCloth;
exports.isDragCreatingCloth = isDragCreatingCloth;
const config_1 = require("./config");
const vec2_1 = require("./vec2");
/** Next group id counter */
let nextGroup = 0;
function nextGroupID() { return ++nextGroup; }
function addPoint(world, x, y, pin = false) {
const idx = world.points.length;
const vx = 0;
const vy = 0;
world.points.push({
pos: (0, vec2_1.vec2)(x, y),
prev: (0, vec2_1.vec2)(x - vx, y - vy),
force: (0, vec2_1.vec2)(0, 0),
pin,
radius: 4,
group: 0, // set by caller
tearMask: 0,
vx, vy,
});
return idx;
}
function addConstraint(world, i, j, restLen, strength = 1) {
const idx = world.constraints.length;
world.constraints.push({
i, j,
restLength: Math.max(restLen, config_1.PHYSICS.MIN_DIST),
strength,
active: true,
group: 0, // set by caller
});
return idx;
}
// ─── Rope ───────────────────────────────────────────────────
/**
* Spawn a rope from (x0,y0) to (x1,y1).
* The start point is pinned.
*/
function spawnRope(world, x0, y0, x1, y1) {
const dx = x1 - x0;
const dy = y1 - y0;
const totalLen = Math.hypot(dx, dy);
let segLen = Math.max(config_1.SPAWN.ROPE_SEG_LEN, totalLen / config_1.SPAWN.ROPE_MIN_POINTS);
const numSegs = Math.max(2, Math.floor(totalLen / segLen));
segLen = totalLen / numSegs;
const gid = nextGroupID();
const positions = [];
for (let s = 0; s <= numSegs; s++) {
const t = s / numSegs;
positions.push((0, vec2_1.vec2)(x0 + dx * t, y0 + dy * t));
}
const startIdx = world.points.length;
for (const p of positions) {
const idx = world.points.length;
world.points.push({
pos: p,
prev: { ...p },
force: (0, vec2_1.vec2)(0, 0),
pin: idx === startIdx,
radius: 4,
group: gid,
tearMask: 0,
vx: 0, vy: 0,
});
}
for (let s = 0; s < numSegs; s++) {
const cIdx = world.constraints.length;
world.constraints.push({
i: startIdx + s,
j: startIdx + s + 1,
restLength: segLen,
strength: 1,
active: true,
group: gid,
});
}
}
// ─── Cloth ──────────────────────────────────────────────────
/**
* Spawn a cloth grid pinned at the top edge.
* @param cols number of points horizontally
* @param rows number of points vertically
* @param spacingpx spacing between points
* @param pinPattern 'corners' | 'top' | 'full' — where to pin
*/
function spawnCloth(world, x, y, cols, rows, spacingpx, pinPattern = 'top') {
const gid = nextGroupID();
const allIndices = [];
// create grid
for (let r = 0; r < rows; r++) {
const rowIdxs = [];
for (let c = 0; c < cols; c++) {
const px = x + c * spacingpx;
const py = y + r * spacingpx;
const idx = addPoint(world, px, py, false);
let pinned = false;
if (pinPattern === 'top' && r === 0)
pinned = true;
else if (pinPattern === 'corners' && r === 0 && (c === 0 || c === cols - 1))
pinned = true;
else if (pinPattern === 'full')
pinned = true;
if (pinned)
world.points[idx].pin = true;
world.points[idx].group = gid;
rowIdxs.push(idx);
}
allIndices.push(rowIdxs);
}
// structural constraints (horizontal + vertical)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = allIndices[r][c];
if (c < cols - 1) {
addConstraint(world, idx, allIndices[r][c + 1], spacingpx, 1);
world.constraints[world.constraints.length - 1].group = gid;
}
if (r < rows - 1) {
addConstraint(world, idx, allIndices[r + 1][c], spacingpx, 1);
world.constraints[world.constraints.length - 1].group = gid;
}
}
}
// shear constraints (diagonal)
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const idx = allIndices[r][c];
const diag1 = allIndices[r + 1][c + 1];
addConstraint(world, idx, diag1, Math.sqrt(2) * spacingpx, 1);
world.constraints[world.constraints.length - 1].group = gid;
}
}
}
// ─── Soft Body ──────────────────────────────────────────────
/**
* Spawn a soft body as a circle of points with structural + brace constraints.
*/
function spawnSoftBody(world, cx, cy, radius, numPoints) {
const gid = nextGroupID();
const indices = [];
for (let i = 0; i < numPoints; i++) {
const angle = (2 * Math.PI * i) / numPoints;
const px = cx + Math.cos(angle) * radius;
const py = cy + Math.sin(angle) * radius;
const idx = addPoint(world, px, py, false);
indices.push(idx);
world.points[idx].group = gid;
}
// add a center point
const centerIdx = addPoint(world, cx, cy, false);
world.points[centerIdx].group = gid;
for (let i = 0; i < numPoints; i++) {
const next = (i + 1) % numPoints;
const prev = (i - 1 + numPoints) % numPoints;
const curr = indices[i];
// structural edges
addConstraint(world, curr, indices[next], radius * 2 * Math.sin(Math.PI / numPoints), 1);
world.constraints[world.constraints.length - 1].group = gid;
// braces to next-next (stiffness)
addConstraint(world, curr, indices[next === numPoints - 1 ? 1 : next + 1], radius * 2 * Math.sin(2 * Math.PI / numPoints), 0.8);
world.constraints[world.constraints.length - 1].group = gid;
// brace to center
addConstraint(world, curr, centerIdx, radius, 0.6);
world.constraints[world.constraints.length - 1].group = gid;
}
}
// ─── Ball ───────────────────────────────────────────────────
/**
* Spawn a ball as a single point.
*/
function spawnBall(world, x, y, radius) {
const gid = nextGroupID();
const idx = addPoint(world, x, y, false);
world.points[idx].radius = radius;
world.points[idx].group = gid;
}
let dragCreate = null;
function beginDragCreateRope(x, y) {
dragCreate = { startX: x, startY: y };
}
function endDragCreateRope(world, endX, endY) {
if (!dragCreate)
return;
const dx = endX - dragCreate.startX;
const dy = endY - dragCreate.startY;
const len = Math.hypot(dx, dy);
if (len > 10) {
spawnRope(world, dragCreate.startX, dragCreate.startY, endX, endY);
}
dragCreate = null;
}
function cancelDragCreate() {
dragCreate = null;
}
function isDragCreating() { return dragCreate !== null; }
let dragCreateCloth = null;
function beginDragCreateCloth(x, y) {
dragCreateCloth = { startX: x, startY: y };
}
function endDragCreateCloth(world, endX, endY, pin) {
if (!dragCreateCloth)
return;
const dx = endX - dragCreateCloth.startX;
const dy = endY - dragCreateCloth.startY;
const maxDim = Math.max(Math.abs(dx), Math.abs(dy));
if (maxDim > 20) {
const cols = Math.max(4, Math.floor(maxDim / config_1.SPAWN.CLOTH_SPACING));
const rows = Math.max(4, Math.floor(maxDim / config_1.SPAWN.CLOTH_SPACING));
const sx = dx >= 0 ? dragCreateCloth.startX : endX;
const sy = dy >= 0 ? dragCreateCloth.startY : endY;
spawnCloth(world, sx, sy, cols, rows, config_1.SPAWN.CLOTH_SPACING, pin);
}
dragCreateCloth = null;
}
function cancelDragCreateCloth() {
dragCreateCloth = null;
}
function isDragCreatingCloth() { return dragCreateCloth !== null; }
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Input — mouse interaction: grab, tear, pin, drag-for-create
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInputState = createInputState;
exports.handleMousedown = handleMousedown;
exports.handleMousemove = handleMousemove;
exports.handleMouseup = handleMouseup;
exports.handleKeydown = handleKeydown;
exports.getGrabInfo = getGrabInfo;
exports.getTearInfo = getTearInfo;
const config_1 = require("./config");
const spawner_1 = require("./spawner");
const GRAB_RADIUS = config_1.RENDER.GRAB_RADIUS;
const TEAR_RADIUS = config_1.RENDER.TEAR_RADIUS;
function createInputState(canvasW, canvasH) {
return {
mouseX: 0, mouseY: 0,
mouseDown: false,
rightDown: false,
shiftDown: false,
grabPoint: null,
grabbedByDragging: false,
dragStartX: 0, dragStartY: 0,
lastMouseX: 0, lastMouseY: 0,
velocityX: 0, velocityY: 0,
canvasW, canvasH,
prevMouseX: 0, prevMouseY: 0,
};
}
/** Find nearest point within GRAB_RADIUS */
function findNearestPoint(world, x, y, maxDist) {
let bestIdx = -1;
let bestDist = maxDist;
for (let i = 0; i < world.points.length; i++) {
const p = world.points[i];
const d = Math.hypot(p.pos.x - x, p.pos.y - y);
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return bestIdx;
}
/** Find constraints passing near (x,y) and mark them broken */
function tearNear(world, x, y) {
const radiusSq = TEAR_RADIUS * TEAR_RADIUS;
for (let ci = 0; ci < world.constraints.length; ci++) {
const c = world.constraints[ci];
if (!c.active)
continue;
const a = world.points[c.i];
const b = world.points[c.j];
// point-to-segment distance
const dx = b.pos.x - a.pos.x;
const dy = b.pos.y - a.pos.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-9)
continue;
// t = projection of (p - a) onto (b - a), clamped [0,1]
const px = x - a.pos.x;
const py = y - a.pos.y;
let t = (px * dx + py * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const closestX = a.pos.x + dx * t;
const closestY = a.pos.y + dy * t;
const distSq = (closestX - x) * (closestX - x) + (closestY - y) * (closestY - y);
if (distSq < radiusSq) {
c.active = false;
c.strength = 0;
}
}
}
/** Toggle pin state of a point */
function togglePin(world, idx) {
if (idx >= 0 && idx < world.points.length) {
world.points[idx].pin = !world.points[idx].pin;
}
}
/**
* Check if user is clicking on a spawnable area (no grab targets nearby).
* Returns true if left-click is in empty space and should create ball.
*/
function isEmptySpace(world, x, y, radius = 30) {
return findNearestPoint(world, x, y, radius) < 0;
}
/** Handle mouse event on canvas. Returns void — input state is mutated in-place. */
function handleMousedown(state, world, button, ctrlKey = false) {
const { mouseX, mouseY } = state;
if (ctrlKey) {
// Ctrl+click: try to pin nearest point
const idx = findNearestPoint(world, mouseX, mouseY, GRAB_RADIUS);
if (idx >= 0) {
togglePin(world, idx);
}
return;
}
if (button === 2) {
// Right click — tear
state.rightDown = true;
tearNear(world, mouseX, mouseY);
}
else if (button === 0) {
// Left click — grab nearest point
const idx = findNearestPoint(world, mouseX, mouseY, GRAB_RADIUS);
if (idx >= 0 && !world.points[idx].pin) {
state.grabPoint = idx;
state.grabbedByDragging = true;
state.dragStartX = world.points[idx].pos.x;
state.dragStartY = world.points[idx].pos.y;
}
else {
// Left click in empty space with Shift → create ball
if (state.shiftDown) {
(0, spawner_1.spawnBall)(world, mouseX, mouseY, 15);
}
}
}
}
/** Handle mousemove — update grab position and tear */
function handleMousemove(state, world) {
state.prevMouseX = state.lastMouseX;
state.prevMouseY = state.lastMouseY;
state.lastMouseX = state.mouseX;
state.lastMouseY = state.mouseY;
if (state.grabPoint !== null) {
const p = world.points[state.grabPoint];
// Move to cursor
p.pos.x = state.mouseX;
p.pos.y = state.mouseY;
// Clear previous position so velocity = 0 when released (thrown via next step)
// Actually, we want the velocity to persist, so set prev = pos after move
p.prev.x = state.mouseX;
p.prev.y = state.mouseY;
// compute velocity from drag motion
state.velocityX = state.mouseX - state.prevMouseX;
state.velocityY = state.mouseY - state.prevMouseY;
}
if (state.rightDown) {
tearNear(world, state.mouseX, state.mouseY);
}
}
/** Handle mouseup — release grab, potentially create object via dragging */
function handleMouseup(state, world, button) {
if (button === 0) {
if (state.grabPoint !== null) {
// Release the grabbed point — velocity is already set from drag
state.grabPoint = null;
state.grabbedByDragging = false;
}
}
else if (button === 2) {
state.rightDown = false;
}
}
/** Keyboard handling */
function handleKeydown(key) {
switch (key) {
case ' ': return 'pause';
case 'ArrowRight': return 'step';
case 'ArrowUp': return 'gravity-toggle';
case 'ArrowDown': return 'wind-toggle';
default: return '';
}
}
/** Get the grab point for rendering */
function getGrabInfo(state, world) {
if (state.grabPoint !== null) {
const p = world.points[state.grabPoint];
return { idx: state.grabPoint, x: p.pos.x, y: p.pos.y };
}
return null;
}
/** Get the tear indicator position for rendering */
function getTearInfo(state) {
if (state.rightDown) {
return { x: state.mouseX, y: state.mouseY };
}
return null;
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Render — Canvas 2D rendering with stress visualization & HUD
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRenderState = createRenderState;
exports.setShowStress = setShowStress;
exports.setDragRopePreview = setDragRopePreview;
exports.updateDragRopePreview = updateDragRopePreview;
exports.clearDragRopePreview = clearDragRopePreview;
exports.isDraggingRope = isDraggingRope;
exports.stressColor = stressColor;
exports.render = render;
const config_1 = require("./config");
const physics_1 = require("./physics");
const input_1 = require("./input");
function createRenderState(ctx, cw, ch) {
return {
ctx, canvasW: cw, canvasH: ch,
fps: 0, frameCount: 0,
lastFpsTime: performance.now(),
lastStepTime: 0,
};
}
let _showStress = false;
function setShowStress(v) { _showStress = v; }
// ─────────────────────────────────────────────────────────
// Shared drag preview state (for main ↔ render coordination)
// ─────────────────────────────────────────────────────────
function setDragRopePreview(startX, startY) {
// No-op now that drag preview state has been removed
}
function updateDragRopePreview(x, y) {
// No-op
}
function clearDragRopePreview() {
// No-op
}
function isDraggingRope() { return false; }
// ─────────────────────────────────────────────────────────
// Helper: stress color
// ─────────────────────────────────────────────────────────
function stressColor(ratio) {
const clamped = Math.min(Math.abs(ratio), 1.0);
const r = Math.round(50 + 205 * clamped);
const g = Math.round(255 * (1 - clamped));
return `rgb(${r},${g},40)`;
}
// ─────────────────────────────────────────────────────────
// Group color helpers
// ─────────────────────────────────────────────────────────
function getGroupType(group) {
return Math.abs(group) % 100;
}
function groupColor(type) {
switch (type) {
case 1: return config_1.COLORS.ROPE;
case 2: return config_1.COLORS.CLOTH;
case 3: return config_1.COLORS.SOFT_BODY;
case 4: return config_1.COLORS.BALL;
default: return '#8888aa';
}
}
function groupColorAlpha(type, alpha) {
const hex = groupColor(type);
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
function buildGroups(world) {
const map = new Map();
for (let ci = 0; ci < world.constraints.length; ci++) {
const c = world.constraints[ci];
if (!c.active)
continue;
let g = map.get(c.group);
if (!g) {
g = { group: c.group, type: getGroupType(c.group), points: [], constraints: [] };
map.set(c.group, g);
}
g.constraints.push(ci);
if (!g.points.includes(c.i))
g.points.push(c.i);
if (!g.points.includes(c.j))
g.points.push(c.j);
}
return map;
}
// ─────────────────────────────────────────────────────────
// Main render function
// ─────────────────────────────────────────────────────────
function render(world, state, input) {
const ctx = state.ctx;
const W = state.canvasW;
const H = state.canvasH;
// Background
ctx.fillStyle = config_1.COLORS.BG;
ctx.fillRect(0, 0, W, H);
// Subtle grid
drawGrid(ctx, W, H);
// Obstacles
drawObstacles(ctx, world);
// Build groups from active constraints
const groups = buildGroups(world);
// Draw fills for group types that support it
drawGroupFills(ctx, world, groups);
// Draw constraints
drawConstraints(ctx, world);
// Draw points (dots) — only for non-cloth types
drawPointDots(ctx, world);
// Pin indicators
drawPins(ctx, world);
// Grab highlight ring
const grabInfo = (0, input_1.getGrabInfo)(input, world);
if (grabInfo) {
drawGrabRing(ctx, grabInfo.x, grabInfo.y);
}
// Tear indicator (disabled to fix TS narrowing)
// Drag preview disabled
// HUD update
updateFPS(state);
drawHUD(ctx, state, world);
}
// ─────────────────────────────────────────────────────────
// Grid
// ─────────────────────────────────────────────────────────
function drawGrid(ctx, w, h) {
ctx.strokeStyle = config_1.COLORS.GRID;
ctx.lineWidth = 0.4;
const sp = 40;
for (let x = sp; x < w; x += sp) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = sp; y < h; y += sp) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
// ─────────────────────────────────────────────────────────
// Obstacles
// ─────────────────────────────────────────────────────────
function drawObstacles(ctx, world) {
for (const obs of world.obstacles) {
if (obs.type === 'rect') {
const grad = ctx.createLinearGradient(obs.x, obs.y, obs.x, obs.y + obs.h);
grad.addColorStop(0, '#3a3a5e');
grad.addColorStop(1, '#2a2a42');
ctx.fillStyle = grad;
ctx.fillRect(obs.x, obs.y, obs.w, obs.h);
ctx.strokeStyle = '#555588';
ctx.lineWidth = 1.5;
ctx.strokeRect(obs.x, obs.y, obs.w, obs.h);
}
else if (obs.type === 'circle') {
const grad = ctx.createRadialGradient(obs.x, obs.y, 0, obs.x, obs.y, obs.r);
grad.addColorStop(0, '#4a4a72');
grad.addColorStop(1, '#2a2a42');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(obs.x, obs.y, obs.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#555588';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
// ─────────────────────────────────────────────────────────
// Group fills (cloth mesh, soft body blob)
// ─────────────────────────────────────────────────────────
function drawGroupFills(ctx, world, groups) {
for (const g of groups.values()) {
if (g.type === 2) {
drawClothFill(ctx, world, g);
}
else if (g.type === 3) {
drawSoftBodyFill(ctx, g.points.map(i => world.points[i]));
}
else if (g.type === 4) {
const pt = world.points[g.points[0]];
if (pt) {
const r = pt.radius;
ctx.fillStyle = groupColorAlpha(4, config_1.RENDER.BALL_OPACITY);
ctx.beginPath();
ctx.arc(pt.pos.x, pt.pos.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = groupColor(4);
ctx.lineWidth = 1.5;
ctx.stroke();
// Shine
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.arc(pt.pos.x - r * 0.3, pt.pos.y - r * 0.3, r * 0.4, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
/** Draw cloth as triangular mesh fill */
function drawClothFill(ctx, world, g) {
if (g.points.length < 4)
return;
const pts = g.points.map(i => ({ x: world.points[i].pos.x, y: world.points[i].pos.y }));
// Y-cluster
const clusters = [];
const yThresh = 6;
for (const pt of pts) {
let placed = false;
for (const cl of clusters) {
if (Math.abs(cl[0].y - pt.y) < yThresh) {
cl.push(pt);
placed = true;
break;
}
}
if (!placed)
clusters.push([pt]);
}
clusters.sort((a, b) => a[0].y - b[0].y);
for (const cl of clusters)
cl.sort((a, b) => a.x - b.x);
if (clusters.length < 2)
return;
for (let row = 0; row < clusters.length - 1; row++) {
const upper = clusters[row];
const lower = clusters[row + 1];
for (let c = 0; c < upper.length - 1; c++) {
const up0 = upper[c], up1 = upper[c + 1];
const lo0opt = findClosestInRow(lower, up0.x, up1.x);
if (!lo0opt)
continue;
const lo0 = lo0opt;
const lo1opt = findClosestInRow(lower.slice(1), lo0.x + 1, up1.x);
if (lo1opt) {
fillTri(ctx, up0, up1, lo0);
fillTri(ctx, up1, lo1opt, lo0);
}
else {
fillTri(ctx, up0, up1, lo0);
}
}
}
}
function findClosestInRow(row, targetX, upperX) {
let best = null;
let bestDist = Infinity;
for (const p of row) {
const d = Math.hypot(p.x - targetX, p.y - upperX); // rough
if (d < bestDist) {
bestDist = d;
best = p;
}
}
return best || row[0] || null;
}
function fillTri(ctx, a, b, c) {
ctx.fillStyle = groupColorAlpha(2, config_1.RENDER.CLOTH_OPACITY);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineTo(c.x, c.y);
ctx.closePath();
ctx.fill();
}
/** Draw soft body convex hull */
function drawSoftBodyFill(ctx, pts) {
if (pts.length < 3)
return;
let cx = 0, cy = 0;
for (const p of pts) {
cx += p.pos.x;
cy += p.pos.y;
}
cx /= pts.length;
cy /= pts.length;
const sorted = [...pts].sort((a, b) => Math.atan2(a.pos.y - cy, a.pos.x - cx) - Math.atan2(b.pos.y - cy, b.pos.x - cx));
ctx.fillStyle = groupColorAlpha(3, config_1.RENDER.SOFT_BODY_OPACITY);
ctx.beginPath();
ctx.moveTo(sorted[0].pos.x, sorted[0].pos.y);
for (let i = 1; i < sorted.length; i++)
ctx.lineTo(sorted[i].pos.x, sorted[i].pos.y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = groupColor(3);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// ─────────────────────────────────────────────────────────
// Constraints
// ─────────────────────────────────────────────────────────
function drawConstraints(ctx, world) {
for (let ci = 0; ci < world.constraints.length; ci++) {
const c = world.constraints[ci];
if (!c.active)
continue;
const a = world.points[c.i], b = world.points[c.j];
const type = getGroupType(c.group);
if (_showStress) {
const stress = (0, physics_1.getConstraintStress)(c, world.points);
ctx.strokeStyle = stressColor(stress);
}
else {
ctx.strokeStyle = groupColor(type);
}
ctx.lineWidth = config_1.RENDER.LINE_WIDTH;
ctx.beginPath();
ctx.moveTo(a.pos.x, a.pos.y);
ctx.lineTo(b.pos.x, b.pos.y);
ctx.stroke();
}
}
// ─────────────────────────────────────────────────────────
// Points (dots) — non-cloth
// ─────────────────────────────────────────────────────────
function drawPointDots(ctx, world) {
const r = config_1.RENDER.DOT_RADIUS;
for (const p of world.points) {
if (getGroupType(p.group) === 2)
continue;
ctx.fillStyle = groupColor(p.group);
ctx.beginPath();
ctx.arc(p.pos.x, p.pos.y, r, 0, Math.PI * 2);
ctx.fill();
// Glow for soft bodies/balls
const t = getGroupType(p.group);
if (t === 3 || t === 4) {
const grad = ctx.createRadialGradient(p.pos.x, p.pos.y, r, p.pos.x, p.pos.y, r * 2.5);
grad.addColorStop(0, groupColorAlpha(t, 0.15));
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.pos.x, p.pos.y, r * 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ─────────────────────────────────────────────────────────
// Pin indicators
// ─────────────────────────────────────────────────────────
function drawPins(ctx, world) {
const pr = config_1.RENDER.DOT_RADIUS + 3;
ctx.strokeStyle = config_1.COLORS.PIN;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.6;
for (const p of world.points) {
if (!p.pin)
continue;
ctx.beginPath();
ctx.moveTo(p.pos.x - pr, p.pos.y);
ctx.lineTo(p.pos.x + pr, p.pos.y);
ctx.moveTo(p.pos.x, p.pos.y - pr);
ctx.lineTo(p.pos.x, p.pos.y + pr);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// ─────────────────────────────────────────────────────────
// Grab highlight
// ─────────────────────────────────────────────────────────
function drawGrabRing(ctx, x, y) {
const r = config_1.RENDER.GRAB_RADIUS;
ctx.strokeStyle = config_1.COLORS.GRAB;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.6;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
// ─────────────────────────────────────────────────────────
// Drag rope preview
// ─────────────────────────────────────────────────────────
function drawRopeDragPreview(ctx, startX, startY, endX, endY) {
const dx = endX - startX, dy = endY - startY;
const len = Math.hypot(dx, dy);
if (len < 10)
return;
const segLen = Math.max(config_1.SPAWN.ROPE_SEG_LEN, len / config_1.SPAWN.ROPE_MIN_POINTS);
const numSegs = Math.max(2, Math.floor(len / segLen));
ctx.strokeStyle = config_1.COLORS.ROPE + '88';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(startX, startY);
for (let s = 1; s <= numSegs; s++) {
const t = s / numSegs;
ctx.lineTo(startX + dx * t, startY + dy * t);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = config_1.COLORS.ROPE;
ctx.beginPath();
ctx.arc(startX, startY, 5, 0, Math.PI * 2);
ctx.fill();
}
// ─────────────────────────────────────────────────────────
// FPS & HUD
// ─────────────────────────────────────────────────────────
function updateFPS(state) {
const now = performance.now();
state.frameCount++;
const delta = now - state.lastFpsTime;
if (delta >= 500) {
state.fps = Math.round(state.frameCount / (delta / 1000));
state.frameCount = 0;
state.lastFpsTime = now;
}
}
function drawHUD(ctx, state, world) {
// In-canvas HUD
const W = state.canvasW;
const panelW = 200;
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.roundRect(W - panelW - 4, 4, panelW, 26, 4);
ctx.fill();
ctx.fillStyle = config_1.COLORS.HUD_TEXT;
ctx.font = '11px monospace';
ctx.textBaseline = 'top';
const msg = `FPS: ${state.fps} | ${world.points.length} pts | ${world.constraints.length} constraints | ${state.lastStepTime.toFixed(1)}ms`;
ctx.fillText(msg, W - panelW, 7);
// Bottom bar (live stats in DOM)
const hudEl = document.getElementById('hud-info');
if (hudEl && state.frameCount % 8 === 0) {
hudEl.textContent = `FPS: ${state.fps} | Points: ${world.points.length} | Constraints: ${world.constraints.length} | Step: ${state.lastStepTime.toFixed(1)}ms | ${world.paused ? '⏸ PAUSED' : world.gravityOn ? '⚡' : '🪶'}`;
}
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.7-flash","provider":"openrouter","persona":"typescript","sessionId":"4a537b52-50e9-4896-bf6f-8b56211e2fb1","tokensIn":6438670,"tokensOut":76658,"tokensTotal":6515328,"turns":97,"toolCalls":96,"failedToolCalls":6,"timestamp":"2026-07-30T16:36:28.239Z"} -->