From 90a3446bdd43e8fa74450689e0c50f2d83d99bfb Mon Sep 17 00:00:00 2001 From: B Wu Date: Tue, 13 Aug 2024 22:35:51 -0700 Subject: [PATCH] feat: implement state machine for creatures --- static/index.js | 582 +++++++++++++++++++++++++++++++++++++++++++---- static/style.css | 17 +- 2 files changed, 555 insertions(+), 44 deletions(-) diff --git a/static/index.js b/static/index.js index 088f304..7f7bbf2 100644 --- a/static/index.js +++ b/static/index.js @@ -5,6 +5,24 @@ const CREATURE_WIDTH = 32; // in px 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 NUMERICAL_TOLERANCE = 0.01; + +/** + * Enum of directions + * @readonly + * @enum {number} + */ +const Direction = Object.freeze({ + NORTH: 0, + EAST: 1, + SOUTH: 2, + WEST: 3, + NORTHEAST: 4, + SOUTHEAST: 5, + SOUTHWEST: 6, + NORTHWEST: 7, +}) /** * Enum of creature states @@ -31,9 +49,20 @@ const CreatureState = Object.freeze({ WALK_NORTHWEST: 16, }) +const WALKING_CREATURE_STATES = Object.freeze([ + CreatureState.WALK_NORTH, + CreatureState.WALK_NORTHEAST, + CreatureState.WALK_EAST, + CreatureState.WALK_SOUTHEAST, + CreatureState.WALK_SOUTH, + CreatureState.WALK_SOUTHWEST, + CreatureState.WALK_WEST, + CreatureState.WALK_NORTHWEST, +]) + /** - * @typedef {[number, number]} SpriteFrameOffset the offset of the sprite with respect to - * the left/top background position offset (off by factor of sprite size) + * @typedef {[number, number]} SpriteFrameOffset the client of the sprite with respect to + * the left/top background position client (off by factor of sprite size) * @type {Object.>} */ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ @@ -109,6 +138,8 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ // === === +/** @typedef {[number, number]} vec2 a vector 2 components */ + /** * Properties for creatures running around the screen * @typedef {Object} Creature @@ -116,10 +147,11 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ * @property {string} spriteSheet the file name of the sprite sheet. should exist in {@link /static/sprites} * @property {number} state the current state of the creature (should be member of {@link CreatureState} enum) * @property {number} stateDuration the number of frames the creature has been in its current state - * @property {number} positionX the number of pixels away from the left side of the container element - * @property {number} positionY the number of pixels away from the top of the container element + * @property {number} positionX x component of the center of the creature position + * @property {number} positionY y component of the center of the creature position * @property {HTMLElement} element the HTML element rendering the creature in the DOM * @property {HTMLElement} container the HTML element containing the creature (the kennel, if you will) + * @property {?vec2} walkDirection the (normed) direction a creature is walking in, if it's walking. otherwise null. */ // === === @@ -127,15 +159,26 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ // === === /** - * Returns random number between min (inclusive) and max (exclusive) + * Returns a random number between min (inclusive) and max (exclusive). * If max is less than or equal to min, return min * @param {number} min inclusive lower bound * @param {number} max exclusive upper bound * @return {number} number in [min, max) */ -const randomInt = (min, max) => { +const getRandomInRange = (min, max) => { if (max <= min) return min; - return Math.floor(Math.random() * (max - min) + min); + return Math.random() * (max - min) + min; +} + +/** + * Returns random number between min (inclusive) and max (exclusive) + * If max is less than or equal to min, return Math.floor(min) + * @param {number} min inclusive lower bound + * @param {number} max exclusive upper bound + * @return {number} integer in [min, max) + */ +const getRandomInt = (min, max) => { + return Math.floor(getRandomInRange(min, max)); } /** @@ -161,10 +204,136 @@ const normalize = (...components) => { return components.map(component => component / magnitude) } +/** + * Recursive helper for {@link getRandomChoice} + * @template {!*} T + * @param {Array} weights + * @param {Array} choices + * @param {number} random + * @param {number} cumulativeSum + * @return {undefined|T} + * @ignore + */ +const _getRandomChoice = (weights, choices, random, cumulativeSum) => { + const choice = choices.shift(); + + if (choice === undefined) { + return undefined; + } + + const newCumulativeSum = (weights.shift() ?? 0) + cumulativeSum; + + if (random < newCumulativeSum) { + return choice; + } + + return _getRandomChoice(weights, choices, random, newCumulativeSum); +} + +/** + * Given an array of weights and an array of the same length of choices, + * randomly pick an element from the choices whose probability of picking it corresponds + * to the corresponding weight (given position in the array). + * Returns undefined when there are fewer choices than weights + * @template {!*} T type of the choice elements + * @param {?Array} weights if undefined, default to equal probability distribution of weights + * @param {Array} choices + * @return {undefined|T} the chosen element, or undefined if there are more weights than choices + */ +const getRandomChoice = (weights, choices) => { + weights = weights ?? Array(choices.length).fill(1 / choices.length); + + const weightSum = weights.reduce((sum, probability) => sum + probability, 0); + const random = Math.random() * weightSum; + + return _getRandomChoice([...weights], [...choices], random, 0); +} + +/** + * Roll an n-sided, 1-indexed die. + * Return true if it's a nat n (ie the die lands on side n). + * False otherwise. + * @param {number} n number of faces on the die. + * @return {boolean} + */ +const rollForNatN = (n) => { + return Math.random() < 1 / n; +} + +/** + * Returns true if two circles intersect, false otherwise. + * We consider circles to intersect iff there exists a point p that lies in both circles + * @param {number} x0 x component of the center of circle 1 + * @param {number} y0 y component of the center of circle 1 + * @param {number} r0 radius of circle 1 + * @param {number} x1 x component of the center of circle 1 + * @param {number} y1 y component of the center of circle 1 + * @param {number} r1 radius of circle 1 + * @return {boolean} + */ +const isIntersectingCircles = (x0, y0, r0, x1, y1, r1) => { + return (x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1) < (r0 + r1) * (r0 + r1); +} + +/** + * Return true if val in [min, max) + * @param {number} val + * @param {number} min + * @param {number} max + * @return {boolean} + */ +const isBetween = (val, min, max) => { + return val >= min && val < max; +} + // === === // === === +/** + * Given a normalized direction a creature is walking in, return + * the creature state that corresponds closest to the direction. + * Defaults to WALK_NORTH if something goes wrong. + * @param {vec2} direction + * @return {number} corresponding {@link CreatureState} + */ +const getWalkStateFromDirection = (direction) => { + const directionAngle = Math.atan2(direction.at(0), direction.at(1)); + if (Number.isNaN(directionAngle)) { + return CreatureState.WALK_NORTH; // default + } + + if (isBetween(directionAngle, Math.PI * 5/8, Math.PI * 7/8)) { + return CreatureState.WALK_NORTHEAST; + } + + if (isBetween(directionAngle, Math.PI * 3/8, Math.PI * 5/8)) { + return CreatureState.WALK_EAST; + } + + if (isBetween(directionAngle, Math.PI / 8, Math.PI * 3/8)) { + return CreatureState.WALK_SOUTHEAST; + } + + if (isBetween(directionAngle, -Math.PI / 8, Math.PI / 8)) { + return CreatureState.WALK_SOUTH; + } + + if (isBetween(directionAngle, -Math.PI * 3/8, -Math.PI / 8)) { + return CreatureState.WALK_SOUTHWEST; + } + + if (isBetween(directionAngle, -Math.PI * 5/8, -Math.PI * 3/8)) { + return CreatureState.WALK_WEST; + } + + if (isBetween(directionAngle, -Math.PI * 5/8, -Math.PI * 7/8)) { + return CreatureState.WALK_NORTHWEST; + } + + return CreatureState.WALK_NORTH; +} + /** * Return a new updateCreature function that implicitly accepts the context * of all other creatures being rendered. @@ -194,8 +363,10 @@ const renderCreature = (creature) => { creature.element.style.setProperty('display', 'block'); // set position - creature.element.style.setProperty('left', `${creature.positionX}px`); - creature.element.style.setProperty('top', `${creature.positionY}px`); + const positionX = creature.positionX - (0.5 * CREATURE_WIDTH) + creature.container.clientLeft + const positionY = creature.positionY - (0.5 * CREATURE_HEIGHT) + creature.container.clientTop + creature.element.style.setProperty('left', `${positionX}px`); + creature.element.style.setProperty('top', `${positionY}px`); // set sprite const spriteFrames = CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES[creature.state] @@ -229,7 +400,7 @@ const createCreature = ( const creatureEle = document.createElement('div'); const spriteSheetUrl = spriteSheet ? `url('/static/sprites/${spriteSheet}')` - : `url('/static/sprites/defaults/${randomInt(1, DEFAULT_SPRITE_SHEET_COUNT)}.gif')`; + : `url('/static/sprites/defaults/${getRandomInt(1, DEFAULT_SPRITE_SHEET_COUNT)}.gif')`; creatureEle.setAttribute('id', name); creatureEle.style.setProperty('width', `${CREATURE_WIDTH}px`); @@ -274,43 +445,378 @@ const beginCreatureAnimation = ( // === === +/** + * Returns true if any point in creature 1's collision box is in creature 2's collision box + * @param {Creature} creature1 + * @param {Creature} creature2 + * @return {boolean} + */ +const isCreaturesColliding = (creature1, creature2) => { + return isIntersectingCircles( + creature1.positionX, creature1.positionY, CREATURE_WIDTH * 3/4, + creature2.positionX, creature2.positionY, CREATURE_WIDTH * 3/4, + ) +} + +/** + * Returns the direction of the wall of collision if it collides. Otherwise, return undefined. + * @param {Creature} creature + * @return {number | undefined} + */ +const isCreatureOutOfBounds = (creature) => { + if (creature.positionX - CREATURE_WIDTH / 2 < creature.container.clientLeft) { + return Direction.WEST; + } + + if (creature.positionX + CREATURE_WIDTH / 2 > creature.container.clientLeft + creature.container.clientWidth) { + return Direction.EAST; + } + + if (creature.positionY - CREATURE_HEIGHT / 2 < creature.container.clientTop) { + return Direction.NORTH; + } + + if (creature.positionY + CREATURE_HEIGHT / 2 > creature.container.clientTop + creature.container.clientHeight) { + return Direction.SOUTH; + } + + return undefined; +} + +/** + * Get the creature in the walk state moving toward a destination + * @param {Creature} creature + * @param {vec2} destination + * @return {Creature} the creature in a walking state + */ +const startCreatureWalkTowardDirection = (creature, destination) => { + const walkDirection = /** @type {vec2} */ normalize( + destination.at(0) - creature.positionX, + destination.at(1) - creature.positionY, + ); + const state = getWalkStateFromDirection(walkDirection); + return { + ...creature, + walkDirection, + state, + stateDuration: state === creature.state ? creature.stateDuration + 1 : 0, + positionX: creature.positionX + walkDirection.at(0) * WALK_SIZE, + positionY: creature.positionY + walkDirection.at(1) * WALK_SIZE, + }; +} +/** + * Update function for creatures in any scratch state. * @param {Creature} creature current creature + * @param {Creature} creature + * @return {Creature} updated creature + */ +const updateScratchStateCreature = (creature) => { + if (creature.stateDuration % 2 < 1) { + // animation hasn't finished playing. let it finish. + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + if (rollForNatN(4)) { + return { ...creature, state: CreatureState.IDLE, stateDuration: 0 }; + } + + return { ...creature, stateDuration: creature.stateDuration + 1 }; +} + +/** + * Used for {@link updateWalkStateCreature} to map the output of wall collision to new state + * @type {Readonly>} + * @ignore + */ +const WALL_DIRECTION_TO_SCRATCH_STATE = Object.freeze({ + [Direction.NORTH]: CreatureState.SCRATCH_NORTH, + [Direction.EAST]: CreatureState.SCRATCH_EAST, + [Direction.SOUTH]: CreatureState.SCRATCH_SOUTH, + [Direction.WEST]: CreatureState.SCRATCH_WEST, +}) + +/** + * Update function for creatures in any walk state. + * @param {Creature} creature current creature + * @param {Array} allCreatures all creatures in kennel + * @return {Creature} updated creature + */ +const updateWalkStateCreature = (creature, allCreatures) => { + const newPositionX = creature.positionX + (creature.walkDirection?.at(0) ?? 0) * WALK_SIZE; + const newPositionY = creature.positionY + (creature.walkDirection?.at(1) ?? 0) * WALK_SIZE; + const newPositionCreature = { + ...creature, + positionX: newPositionX, + positionY: newPositionY, + stateDuration: creature.stateDuration + 1, + } + + const wallCollisionDirection = isCreatureOutOfBounds({ + ...creature, positionX: newPositionX, positionY: newPositionY + }) + + if (wallCollisionDirection !== undefined) { + return { + ...creature, + state: WALL_DIRECTION_TO_SCRATCH_STATE[wallCollisionDirection] ?? CreatureState.IDLE, + walkDirection: undefined, + stateDuration: 0, + positionX: constrain( + newPositionX, + creature.container.clientLeft + CREATURE_WIDTH / 2, + creature.container.clientLeft + creature.container.clientWidth - CREATURE_WIDTH / 2, + ), + positionY: constrain( + newPositionY, + creature.container.clientTop + CREATURE_HEIGHT / 2, + creature.container.clientTop + creature.container.clientHeight - CREATURE_HEIGHT / 2, + ), + } + } + + const collidedCreature = allCreatures.reduce((prevCollidedCreature, currentCreature) => { + if (prevCollidedCreature !== undefined) { + return prevCollidedCreature; + } + + if (currentCreature.name === creature.name) { + return undefined; + } + + if (isCreaturesColliding(newPositionCreature, currentCreature)) { + return currentCreature; + } + + return undefined; + }, undefined); + + if (collidedCreature !== undefined) { + return { ...creature, walkDirection: undefined, stateDuration: 0, state: CreatureState.ALERT }; + } + + if (creature.stateDuration < 8 || !rollForNatN(10)) { + // always play for at least 8 frames + return newPositionCreature; + } + + return { ...creature, walkDirection: undefined, stateDuration: 0, state: CreatureState.IDLE }; +} + +/** + * Creature state transitions + * @type {Object.): Creature>} + */ +const CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION = Object.freeze({ + [CreatureState.IDLE]: (creature, allCreatures) => { + if (creature.stateDuration < 8) { + // play for at least 8 frames + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + const spottedCreatures = allCreatures + .filter((otherCreature) => isIntersectingCircles( + // circle at creature position with a view distance radius + creature.positionX, creature.positionY, CREATURE_WIDTH * 4, + otherCreature.positionX, otherCreature.positionY, 0, + )) + .filter((otherCreature) => otherCreature.name !== creature.name) + + if (spottedCreatures.length > 1) { + // run away from the centroid of close creatures + const centroidX = spottedCreatures.reduce( + (sum, spottedCreature) => sum + spottedCreature.positionX, 0, + ) / spottedCreatures.length; + const centroidY = spottedCreatures.reduce( + (sum, spottedCreature) => sum + spottedCreature.positionY, 0 + ) / spottedCreatures.length; + return startCreatureWalkTowardDirection(creature, [ + creature.positionX - centroidX, + creature.positionY - centroidY, + ]) + } + + // move toward spotted creature, so long as doing so won't cause collision + const updatedTowardSpottedCreature = spottedCreatures + .map((otherCreature) => { + const updatedCreature = startCreatureWalkTowardDirection(creature, [ + otherCreature.positionX, otherCreature.positionY, + ]); + if (isCreaturesColliding(updatedCreature, otherCreature)) { + return undefined; + } + return updatedCreature; + }) + .filter((updatedCreature) => updatedCreature !== undefined) + .shift(); + + if (updatedTowardSpottedCreature !== undefined && rollForNatN(4)) { + // if there's a creature in the view radius, move toward it 25% of the time + return updatedTowardSpottedCreature; + } + + const newState = getRandomChoice( + [0.1, 0.1, 0.1, 0.1, 0.6], + [ + CreatureState.ALERT, // 0.1 + CreatureState.SCRATCH_SELF, // 0.1 + CreatureState.TIRED, // 0.1 + CreatureState.WALK_NORTH, // 0.1 + CreatureState.IDLE, // 0.1 + ], + ) + + if (newState === CreatureState.IDLE || newState === undefined) { + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + if (newState !== CreatureState.WALK_NORTH) { + return { + ...creature, + stateDuration: 0, + state: newState, + } + } + + const updatedWalkingCreature = startCreatureWalkTowardDirection(creature, [ + getRandomInRange(creature.container.clientLeft, creature.container.clientLeft + creature.container.clientWidth), + getRandomInRange(creature.container.clientTop, creature.container.clientTop + creature.container.clientHeight), + ]); + + // check for collision + const collidingCreature = allCreatures + .filter((otherCreature) => otherCreature.name !== updatedWalkingCreature.name) + .filter((otherCreature) => isCreaturesColliding(updatedWalkingCreature, otherCreature)) + .shift(); + + if (collidingCreature !== undefined) { + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + return updatedWalkingCreature; + }, + [CreatureState.ALERT]: (creature, allCreatures) => { + if (creature.stateDuration < 3) { + // always play for at three frames + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + const newState = getRandomChoice( + [0.1, 0.1, 0.3, 0.5], + [ + CreatureState.SCRATCH_SELF, // 0.1 + CreatureState.TIRED, // 0.1 + CreatureState.IDLE, // 0.3 + CreatureState.WALK_NORTH, // 0.5 + ] + ) + + if (newState === undefined) { + // default if something goes wrong + return { ...creature, state: CreatureState.IDLE, stateDuration: 0 }; + } + + if (newState === CreatureState.WALK_NORTH) { + // walk toward center with some random client + return startCreatureWalkTowardDirection(creature, [ + getRandomInRange(creature.container.clientLeft, creature.container.clientLeft + creature.container.clientWidth), + getRandomInRange(creature.container.clientTop, creature.container.clientTop + creature.container.clientHeight), + ]); + } + + return { ...creature, state: newState, stateDuration: 0 } + }, + [CreatureState.SCRATCH_SELF]: (creature, allCreatures) => { + if (creature.stateDuration % 3 < 2) { + // animation hasn't finished playing play until complete + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + if (rollForNatN(4)) { + return { ...creature, state: CreatureState.IDLE, stateDuration: 0 }; + } + + return { ...creature, stateDuration: creature.stateDuration + 1 }; + }, + [CreatureState.SCRATCH_NORTH]: updateScratchStateCreature, + [CreatureState.SCRATCH_SOUTH]: updateScratchStateCreature, + [CreatureState.SCRATCH_EAST]: updateScratchStateCreature, + [CreatureState.SCRATCH_WEST]: updateScratchStateCreature, + [CreatureState.TIRED]: (creature, allCreatures) => { + if (creature.stateDuration < 8) { + // play for eight frames + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + if (rollForNatN(20)) { + return { ...creature, state: CreatureState.ALERT, stateDuration: 0 }; + } + + return { ...creature, state: CreatureState.SLEEPING, stateDuration: 0 } + }, + [CreatureState.SLEEPING]: (creature, allCreatures) => { + if (creature.stateDuration < 8) { + // play for at least eight frames + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + const newState = getRandomChoice( + [0.01, 0.02, 0.02, 0.95], + [ + CreatureState.TIRED, // 0.01 + CreatureState.ALERT, // 0.02 + CreatureState.IDLE, // 0.02 + CreatureState.SLEEPING, // 0.95 + ], + ); + + if (newState === CreatureState.SLEEPING || newState === undefined) { + return { ...creature, stateDuration: creature.stateDuration + 1 }; + } + + return { ...creature, state: newState, stateDuration: 0 }; + }, + [CreatureState.WALK_NORTH]: updateWalkStateCreature, + [CreatureState.WALK_NORTHEAST]: updateWalkStateCreature, + [CreatureState.WALK_EAST]: updateWalkStateCreature, + [CreatureState.WALK_SOUTHEAST]: updateWalkStateCreature, + [CreatureState.WALK_SOUTH]: updateWalkStateCreature, + [CreatureState.WALK_SOUTHWEST]: updateWalkStateCreature, + [CreatureState.WALK_WEST]: updateWalkStateCreature, + [CreatureState.WALK_NORTHWEST]: updateWalkStateCreature, +}) + 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 + return; + } + // EXAMPLE SCRIPT const kennelWindowEle = document.querySelector('#kennel-window'); - const creatures = ['test-creature-1', 'test-creature-2', 'test-creature-3'] - .map((name) => createCreature( + const creatures = Array(32).fill('test-creature') + .map((name, index) => createCreature( kennelWindowEle, - name, + name + index.toString(), undefined, // default sprite sheet - randomInt(0, 17), // random state - randomInt(0, 2) * kennelWindowEle.clientWidth, // randomly left or right side - randomInt(0, 2) * kennelWindowEle.clientHeight, // randomly top or bottom + getRandomChoice(undefined, [ + CreatureState.IDLE, + CreatureState.TIRED, + CreatureState.SCRATCH_SELF, + ]), + getRandomInRange(kennelWindowEle.clientLeft, kennelWindowEle.clientLeft + kennelWindowEle.clientWidth), + getRandomInRange(kennelWindowEle.clientTop, kennelWindowEle.clientTop + kennelWindowEle.clientHeight), )) const updateCreature = updateCreatureWithAllCreaturesContext((creature, allCreatures) => { - // example update creature script to bring creatures to the centroid - - const centroidX = allCreatures.reduce( - (sum, creature) => sum + creature.positionX, - 0, - ) / allCreatures.length; - const centroidY = allCreatures.reduce( - (sum, creature) => sum + creature.positionY, - 0, - ) / allCreatures.length; - - const [deltaX, deltaY] = normalize( - centroidX - creature.positionX, - centroidY - creature.positionY, - ); - - return { - ...creature, - positionX: constrain(creature.positionX + deltaX, 0, creature.container.clientWidth), - positionY: constrain(creature.positionY + deltaY, 0, creature.container.clientHeight), - stateDuration: creature.stateDuration + 1, - } + const updatedCreature = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state](creature, allCreatures); + return updatedCreature }, creatures); creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature)) } + +window.onresize = () => { + // TODO: dynamically change creature size based on window size +} diff --git a/static/style.css b/static/style.css index 73c2486..4935855 100644 --- a/static/style.css +++ b/static/style.css @@ -1,7 +1,12 @@ #kennel-window { - width: 80vw; - min-width: 8rem; - height: 80vw; - min-height: 8rem; - margin: auto; -} \ No newline at end of file + width: 100%; + height: 100vh; + background-color: whitesmoke; + margin: 0; + padding: 0; +} + +body { + margin: 0; + padding: 0; +}