From c37d8d13c712e08eb6100363e59bdced647b0a9b Mon Sep 17 00:00:00 2001 From: B Wu Date: Fri, 16 Aug 2024 09:46:05 -0700 Subject: [PATCH] feat: implement blue noise generation for initial creature positions --- static/index.js | 161 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 137 insertions(+), 24 deletions(-) diff --git a/static/index.js b/static/index.js index e633c7f..7673ecf 100644 --- a/static/index.js +++ b/static/index.js @@ -6,6 +6,7 @@ const FRAME_RATE = 6; // in FPS const FRAME_DELAY = 1 / FRAME_RATE * 1000; // in ms const DEFAULT_SPRITE_SHEET_COUNT = 1; // number of default sprite sheets const WALK_SIZE = 8; // magnitude of position change, in px +const MAX_RETRIES = 10; /** * Enum of directions @@ -274,6 +275,120 @@ const isBetween = (val, min, max) => { return val >= min && val < max; } +/** + * Generate an array of n vec2's such that all vectors lie in + * [left, left + width) x [top, top + height) and are at least radius + * distance away from all other points. + * This function uses almost (but not quite) + * [the poisson disk sampling method]{@link https://a5huynh.github.io/posts/2019/poisson-disk-sampling/} + * insofar that instead of generating points in an annulus around a seed, + * it generates points anywhere else in the bounds. + * @param {number} n the number of points to generate + * @param {number} radius the minimum distance between points + * @param {number} left x lower bound of points + * @param {number} top y lower bound of points + * @param {number} width x width of bounds + * @param {number} height y width of bounds + * @param {number=} maxRetries maximum number of generation attemps before giving up + * @return {Array} + */ +const generateBlueNoise = (n, radius, left, top, width, height, maxRetries = MAX_RETRIES) => { + const gridCellSize = radius / Math.sqrt(2); + const gridWidth = Math.ceil(width / gridCellSize); + const gridHeight = Math.ceil(height / gridCellSize); + const grid = /** @type {Array>} */ [...Array(gridHeight)].map(() => Array(gridWidth)); + + /** + * @param {vec2} point + * @return {vec2} + */ + const getSamplePoint = (point) => { + const randomX = Math.random() * (width - 2 * radius); + const randomY = Math.random() * (height - 2 * radius); + + const samplePointX = left + ( + randomX < (point.at(0) - radius) ? randomX : randomX + 2 * radius + ) + const samplePointY = top + ( + randomY < (point.at(1) - radius) ? randomY : randomY + 2 * radius + ) + + return [samplePointX, samplePointY]; + } + + /** + * @param {vec2} point + * @return {void} + */ + const insertPointIntoGrid = (point) => { + const indexX = Math.floor((point.at(0) - left) / gridCellSize); + const indexY = Math.floor((point.at(1) - top) / gridCellSize); + + grid[indexY][indexX] = point; + } + + const areValid = (point1, point2) => { + const deltaX = point1.at(0) - point2.at(0); + const deltaY = point1.at(1) - point2.at(1); + return (deltaX * deltaX + deltaY * deltaY) > (radius * radius) + } + + /** + * @param {vec2} point + * @return {boolean} + */ + const isValidPoint = (point) => { + const indexX = Math.floor((point.at(0) - left) / gridCellSize); + const indexY = Math.floor((point.at(1) - top) / gridCellSize); + + const testPoints = [ + [indexX - 1, indexY - 1], [indexX, indexY - 1], [indexX + 1, indexY - 1], + [indexX - 1, indexY], [indexX, indexY], [indexX + 1, indexY], + [indexX - 1, indexY + 1], [indexX, indexY + 1], [indexX + 1, indexY + 1], + ] + .filter((indices) => ( + indices.at(0) >= 0 && indices.at(1) >= 0 && indices.at(0) < gridWidth && indices.at(1) < gridHeight + )) + .map((indices) => grid[indices.at(1)][indices.at(0)]) + .filter((point) => point !== undefined); + + return testPoints.every((testPoint) => areValid(point, testPoint)) + } + + // initialize + const initialPoint = [ + left + width / 2, top + height / 2, + ] + insertPointIntoGrid(initialPoint); + const blueNoiseSamples = [initialPoint]; + const activeList = [initialPoint]; + + const findAndInsertPoint = () => { + if (activeList.length === 0) return; + + const activePoint = /** @type {vec2} */ getRandomChoice(undefined, activeList); + const samplePoints = [...Array(MAX_RETRIES)].map(_ => getSamplePoint(activePoint)); + const samplePoint = samplePoints.find(isValidPoint); + + if (samplePoint === undefined) { + // remove active point from active list + const indexOfActivePoint = activeList.indexOf(activePoint); + activeList.splice(indexOfActivePoint, 1); + return; + } + + activeList.push(samplePoint); + blueNoiseSamples.push(samplePoint); + insertPointIntoGrid(samplePoint); + } + + while (activeList.length > 0 && blueNoiseSamples.length < n) { + findAndInsertPoint(); + } + + return blueNoiseSamples.length < n ? [] : blueNoiseSamples; +} + // === === // === === @@ -416,7 +531,6 @@ const createCreature = ( * Start creature animation * @param {Creature} creature * @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity - * (via {@link renderAndUpdateCreature}) */ const beginCreatureAnimation = ( creature, @@ -441,8 +555,8 @@ const beginCreatureAnimation = ( */ const isCreaturesColliding = (creature1, creature2) => { return isIntersectingCircles( - creature1.positionX, creature1.positionY, CREATURE_WIDTH * 1/2, - creature2.positionX, creature2.positionY, CREATURE_WIDTH * 1/2, + creature1.positionX, creature1.positionY, CREATURE_WIDTH / 2, + creature2.positionX, creature2.positionY, CREATURE_WIDTH / 2, ) } @@ -771,35 +885,34 @@ const resolveCollision = (updatedCreature, allCreatures, creature) => { } window.onload = () => { - if ( - window.matchMedia(`(prefers-reduced-motion: reduce)`).matches - || window.matchMedia(`(prefers-reduced-motion: reduce)`).matches - ) { - // user preference for low motion. do not render creatures + // user preference for low motion. do not render creatures + if (window.matchMedia(`(prefers-reduced-motion: reduce)`).matches) { return; } + const TEST_CREATURE_COUNT = 32; const kennelWindowEle = document.querySelector('#kennel-window'); - const creatures = Array(32).fill('test-creature') - .map((name, index) => createCreature( - kennelWindowEle, - name + index.toString(), - undefined, // default sprite sheet - getRandomChoice(undefined, [ + + const startingPoints = generateBlueNoise( + TEST_CREATURE_COUNT, CREATURE_WIDTH, + kennelWindowEle.clientLeft, kennelWindowEle.clientTop, + kennelWindowEle.clientWidth, kennelWindowEle.clientHeight + ) + + const creatures = /** @type {Array} */ startingPoints + .map((startingPoint, index) => createCreature( + kennelWindowEle, + `test-creature-${index}`, + undefined, // use a default sprite sheet CreatureState.IDLE, - CreatureState.TIRED, - CreatureState.SCRATCH_SELF, - ]), - getRandomInRange(kennelWindowEle.clientLeft, kennelWindowEle.clientLeft + kennelWindowEle.clientWidth), - getRandomInRange(kennelWindowEle.clientTop, kennelWindowEle.clientTop + kennelWindowEle.clientHeight), + startingPoint.at(0), + startingPoint.at(1), )) const updateCreature = updateCreatureWithContext((creature, allCreatures) => { - return resolveCollision( - CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state](creature, allCreatures), - allCreatures, - creature, - ); + const updateCreatureFunction = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state]; + const updatedCreature = updateCreatureFunction(creature, allCreatures); + return resolveCollision(updatedCreature, allCreatures, creature); }, creatures); creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))