feat: implement blue noise generation for initial creature positions
This commit is contained in:
parent
7e98ec63ac
commit
c37d8d13c7
157
static/index.js
157
static/index.js
|
@ -6,6 +6,7 @@ const FRAME_RATE = 6; // in FPS
|
||||||
const FRAME_DELAY = 1 / FRAME_RATE * 1000; // in ms
|
const FRAME_DELAY = 1 / FRAME_RATE * 1000; // in ms
|
||||||
const DEFAULT_SPRITE_SHEET_COUNT = 1; // number of default sprite sheets
|
const DEFAULT_SPRITE_SHEET_COUNT = 1; // number of default sprite sheets
|
||||||
const WALK_SIZE = 8; // magnitude of position change, in px
|
const WALK_SIZE = 8; // magnitude of position change, in px
|
||||||
|
const MAX_RETRIES = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum of directions
|
* Enum of directions
|
||||||
|
@ -274,6 +275,120 @@ const isBetween = (val, min, max) => {
|
||||||
return val >= min && val < 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<vec2>}
|
||||||
|
*/
|
||||||
|
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<vec2|undefined>>} */ [...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;
|
||||||
|
}
|
||||||
|
|
||||||
// === </MATH_UTILS> ===
|
// === </MATH_UTILS> ===
|
||||||
|
|
||||||
// === <CREATURE_UTILS> ===
|
// === <CREATURE_UTILS> ===
|
||||||
|
@ -416,7 +531,6 @@ const createCreature = (
|
||||||
* Start creature animation
|
* Start creature animation
|
||||||
* @param {Creature} creature
|
* @param {Creature} creature
|
||||||
* @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity
|
* @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity
|
||||||
* (via {@link renderAndUpdateCreature})
|
|
||||||
*/
|
*/
|
||||||
const beginCreatureAnimation = (
|
const beginCreatureAnimation = (
|
||||||
creature,
|
creature,
|
||||||
|
@ -441,8 +555,8 @@ const beginCreatureAnimation = (
|
||||||
*/
|
*/
|
||||||
const isCreaturesColliding = (creature1, creature2) => {
|
const isCreaturesColliding = (creature1, creature2) => {
|
||||||
return isIntersectingCircles(
|
return isIntersectingCircles(
|
||||||
creature1.positionX, creature1.positionY, CREATURE_WIDTH * 1/2,
|
creature1.positionX, creature1.positionY, CREATURE_WIDTH / 2,
|
||||||
creature2.positionX, creature2.positionY, CREATURE_WIDTH * 1/2,
|
creature2.positionX, creature2.positionY, CREATURE_WIDTH / 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -771,35 +885,34 @@ const resolveCollision = (updatedCreature, allCreatures, creature) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = () => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_CREATURE_COUNT = 32;
|
||||||
const kennelWindowEle = document.querySelector('#kennel-window');
|
const kennelWindowEle = document.querySelector('#kennel-window');
|
||||||
const creatures = Array(32).fill('test-creature')
|
|
||||||
.map((name, index) => createCreature(
|
const startingPoints = generateBlueNoise(
|
||||||
|
TEST_CREATURE_COUNT, CREATURE_WIDTH,
|
||||||
|
kennelWindowEle.clientLeft, kennelWindowEle.clientTop,
|
||||||
|
kennelWindowEle.clientWidth, kennelWindowEle.clientHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
const creatures = /** @type {Array<Creature>} */ startingPoints
|
||||||
|
.map((startingPoint, index) => createCreature(
|
||||||
kennelWindowEle,
|
kennelWindowEle,
|
||||||
name + index.toString(),
|
`test-creature-${index}`,
|
||||||
undefined, // default sprite sheet
|
undefined, // use a default sprite sheet
|
||||||
getRandomChoice(undefined, [
|
|
||||||
CreatureState.IDLE,
|
CreatureState.IDLE,
|
||||||
CreatureState.TIRED,
|
startingPoint.at(0),
|
||||||
CreatureState.SCRATCH_SELF,
|
startingPoint.at(1),
|
||||||
]),
|
|
||||||
getRandomInRange(kennelWindowEle.clientLeft, kennelWindowEle.clientLeft + kennelWindowEle.clientWidth),
|
|
||||||
getRandomInRange(kennelWindowEle.clientTop, kennelWindowEle.clientTop + kennelWindowEle.clientHeight),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
const updateCreature = updateCreatureWithContext((creature, allCreatures) => {
|
const updateCreature = updateCreatureWithContext((creature, allCreatures) => {
|
||||||
return resolveCollision(
|
const updateCreatureFunction = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state];
|
||||||
CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state](creature, allCreatures),
|
const updatedCreature = updateCreatureFunction(creature, allCreatures);
|
||||||
allCreatures,
|
return resolveCollision(updatedCreature, allCreatures, creature);
|
||||||
creature,
|
|
||||||
);
|
|
||||||
}, creatures);
|
}, creatures);
|
||||||
|
|
||||||
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))
|
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))
|
||||||
|
|
Loading…
Reference in New Issue