let elem = document.querySelector('.model');
let rangeElem = document.querySelector('.range');
let fpsElem = document.querySelector('.fps');
let destabilizeElem = document.querySelector('.destabilize');
destabilizeElem.addEventListener('click', evt => {
destabilizeElem.classList.toggle('active');
evt.stopPropagation();
evt.preventDefault();
});
let model = {
pos: [ 0, 0 ],
vel: [ 0, 0 ],
startPos: [ 0, 0 ],
range: 100
};
let reset = ({ startMs, range, vel, ang=0 }) => {
// Start again with `range` representing how far the model
// should travel and `vel` representing its initial speed.
// We will calculate `velMult` to be a value multiplied
// against `vel` each frame, such that the model will
// asymptotically reach a distance of `range`
let [ velX, velY ] = [ Math.sin(ang) * vel, Math.cos(ang) * vel ];
// Note the box-shadow on `rangeElem` is 2px wide, so to
// see the exact range it represents we should subtract
// half that amount. This way the middle of the border
// truly represents a distance of `range`!
rangeElem.style.width = rangeElem.style.height = `${(range - 1) << 1}px`;
rangeElem.style.marginLeft = rangeElem.style.marginTop = `-${range - 1}px`;
elem.transform = 'translate(0, 0)';
model.pos = [ 0, 0 ];
model.vel = [ velX, velY ];
model.startPos = [ 0, 0 ];
model.range = range;
};
let ms = performance.now();
let frame = () => {
let prevFrame = ms;
let dms = (ms = performance.now()) - prevFrame;
let dt = dms * 0.001;
elem.style.transform = `translate(${model.pos[0]}px, ${model.pos[1]}px)`;
// Now `velMult` is different every frame:
let velMag = Math.hypot(...model.vel);
let dx = model.pos[0] - model.startPos[0];
let dy = model.pos[1] - model.startPos[1];
let rangeRemaining = model.range - Math.hypot(dx, dy);
let velMult = 1 - Math.max(0, Math.min(1, dt * velMag / rangeRemaining));
model.pos[0] += model.vel[0] * dt;
model.pos[1] += model.vel[1] * dt;
model.vel[0] *= velMult;
model.vel[1] *= velMult;
fpsElem.textContent = `dms: ${dms.toFixed(2)}`;
// Reset once the velocity has multiplied nearly to 0
if (velMag < 0.05) {
reset({
startMs: ms,
// Note that without `Math.round` results will be *visually* inaccurate
// This is simply a result of css truncating floats in some cases
range: Math.round(50 + Math.random() * 300),
vel: 600 + Math.random() * 1200,
ang: Math.random() * 2 * Math.PI
});
}
};
(async () => {
while (true) {
await new Promise(r => window.requestAnimationFrame(r));
if (destabilizeElem.classList.contains('active')) {
await new Promise(r => setTimeout(r, Math.round(Math.random() * 100)));
}
frame();
}
})();
html, body {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
overflow: hidden;
}
.origin {
position: absolute;
overflow: visible;
left: 50%; top: 50%;
}
.model {
position: absolute;
width: 30px; height: 30px;
margin-left: -15px; margin-top: -15px;
border-radius: 100%;
box-shadow: 0 0 0 2px rgba(200, 0, 0, 0.8);
}
.model::before {
content: ''; position: absolute; display: block;
left: 50%; top: 50%;
width: 4px; height: 4px; margin-left: -2px; margin-top: -2px;
border-radius: 100%;
box-shadow: 0 0 0 2px rgba(200, 0, 0, 0.8);
}
.range {
position: absolute;
width: 100px; height: 100px;
margin-left: -50px; margin-top: -50px;
border-radius: 100%;
box-shadow: 0 0 0 2px rgba(200, 0, 0, 0.5);
}
.fps {
position: absolute;
right: 0; bottom: 0;
height: 20px; line-height: 20px;
white-space: nowrap; overflow: hidden;
padding: 10px;
font-family: monospace;
background-color: rgba(0, 0, 0, 0.1);
}
.destabilize {
position: absolute;
right: 0; bottom: 45px;
height: 20px; line-height: 20px;
white-space: nowrap; overflow: hidden;
padding: 10px;
font-family: monospace;
box-shadow: inset 0 0 0 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.destabilize.active { box-shadow: inset 0 0 0 4px rgba(255, 130, 0, 0.9); }
<div class="origin">
<div class="model"></div>
<div class="range"></div>
</div>
<div class="destabilize">Destabilize</div>
<div class="fps"></div>