kennel/static/index.js

823 lines
28 KiB
JavaScript
Raw Normal View History

// === <CONSTANTS> ===
2024-08-10 00:15:49 -04:00
const CREATURE_HEIGHT = 32; // in px
const CREATURE_WIDTH = 32; // in px
const FRAME_RATE = 6; // in FPS
2024-08-10 00:15:49 -04:00
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,
})
2024-08-10 00:15:49 -04:00
/**
* Enum of creature states
* @readonly
* @enum {number}
*/
const CreatureState = Object.freeze({
IDLE: 0,
ALERT: 1,
SCRATCH_SELF: 2,
SCRATCH_NORTH: 3,
SCRATCH_SOUTH: 4,
SCRATCH_EAST: 5,
SCRATCH_WEST: 6,
TIRED: 7,
SLEEPING: 8,
WALK_NORTH: 9,
WALK_NORTHEAST: 10,
WALK_EAST: 11,
WALK_SOUTHEAST: 12,
WALK_SOUTH: 13,
WALK_SOUTHWEST: 14,
WALK_WEST: 15,
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,
])
2024-08-10 00:15:49 -04:00
/**
* @typedef {[number, number]} SpriteFrameOffset the client of the sprite with respect to
* the left/top background position client (off by factor of sprite size)
2024-08-10 00:15:49 -04:00
* @type {Object.<number, Array<SpriteFrameOffset>>}
*/
const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({
[CreatureState.IDLE]: [
[-3, -3]
],
[CreatureState.ALERT]: [
[-7, -3]
],
[CreatureState.SCRATCH_SELF]: [
[-5, 0],
[-6, 0],
[-7, 0],
],
[CreatureState.SCRATCH_NORTH]: [
[0, 0],
[0, -1],
],
[CreatureState.SCRATCH_SOUTH]: [
[-7, -1],
[-6, -2],
],
[CreatureState.SCRATCH_EAST]: [
[-2, -2],
[-2, -3],
],
[CreatureState.SCRATCH_WEST]: [
[-4, 0],
[-4, -1],
],
[CreatureState.TIRED]: [
[-3, -2]
],
[CreatureState.SLEEPING]: [
[-2, 0],
[-2, -1],
],
[CreatureState.WALK_NORTH]: [
[-1, -2],
[-1, -3],
],
[CreatureState.WALK_NORTHEAST]: [
[0, -2],
[0, -3],
],
[CreatureState.WALK_EAST]: [
[-3, 0],
[-3, -1],
],
[CreatureState.WALK_SOUTHEAST]: [
[-5, -1],
[-5, -2],
],
[CreatureState.WALK_SOUTH]: [
[-6, -3],
[-7, -2],
],
[CreatureState.WALK_SOUTHWEST]: [
[-5, -3],
[-6, -1],
],
[CreatureState.WALK_WEST]: [
[-4, -2],
[-4, -3],
],
[CreatureState.WALK_NORTHWEST]: [
[-1, 0],
[-1, -1],
],
});
// === </CONSTANTS> ===
// === <TYPES> ===
/** @typedef {[number, number]} vec2 a vector 2 components */
2024-08-10 00:15:49 -04:00
/**
* Properties for creatures running around the screen
* @typedef {Object} Creature
* @property {string} name the name of the creature, used as the HTML element's id
* @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 x component of the center of the creature position
* @property {number} positionY y component of the center of the creature position
2024-08-10 00:15:49 -04:00
* @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.
2024-08-10 00:15:49 -04:00
*/
// === <TYPES> ===
// === <MATH_UTILS> ===
2024-08-10 00:15:49 -04:00
/**
* Returns a random number between min (inclusive) and max (exclusive).
2024-08-10 00:15:49 -04:00
* 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 getRandomInRange = (min, max) => {
2024-08-10 00:15:49 -04:00
if (max <= min) return 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));
2024-08-10 00:15:49 -04:00
}
/**
* Returns value if value is in [min, max]. Otherwise, bound it to those limits
* @param {number} value
* @param {number} [min] lower bound. if not provided, do not bound the value from beneath
* @param {number} [max] upper bound. if not provided, do not bound the value from above
* @return {number}
*/
const constrain = (value, min = -Number.MAX_VALUE, max = Number.MAX_VALUE) => {
return Math.max(Math.min(value, max), min);
}
/**
* Normalize a vector (ie an array) to be of length 1
* @param {...number} components components of the vector
* @return {number[]} normalized vector
2024-08-10 00:15:49 -04:00
*/
const normalize = (...components) => {
const magnitude = Math.sqrt(
components.reduce((squaredSum, component) => squaredSum + (component * component), 0)
)
return components.map(component => component / magnitude)
}
/**
* Recursive helper for {@link getRandomChoice}
* @template {!*} T
* @param {Array<number>} weights
* @param {Array<T>} 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<number>} weights if undefined, default to equal probability distribution of weights
* @param {Array<T>} 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;
}
// === </MATH_UTILS> ===
// === <CREATURE_UTILS> ===
/**
* 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.
* @param {(creature: Creature, allCreatures?: Array<Creature>) => Creature} updateCreature
* updateCreature function that uses the context of all creatures
* @param {Array<Creature>} allCreatures all creatures rendered in the scene
* @return {(creature: Creature) => Creature} an updateCreature function
*/
const updateCreatureWithAllCreaturesContext = (updateCreature, allCreatures) => {
const creaturesMap = Object.fromEntries(allCreatures.map(
creature => [ creature.name, creature ]
));
return (creature) => {
const updatedCreature = updateCreature(creature, Array.from(Object.values(creaturesMap)));
creaturesMap[updatedCreature.name] = { ...updatedCreature };
return updatedCreature;
}
2024-08-10 00:15:49 -04:00
}
/**
* Render a frame of the creature and loads the next frame for render
* @param {Creature} creature
* @return {Creature} the creature being rendered
2024-08-10 00:15:49 -04:00
*/
const renderCreature = (creature) => {
creature.element.style.setProperty('display', 'block');
2024-08-10 00:15:49 -04:00
// set position
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`);
2024-08-10 00:15:49 -04:00
// set sprite
const spriteFrames = CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES[creature.state]
const currentSpriteFrameOffset = spriteFrames?.[creature.stateDuration % spriteFrames.length]
creature.element.style.setProperty(
'background-position',
`${currentSpriteFrameOffset[0] * CREATURE_WIDTH}px ${currentSpriteFrameOffset[1] * CREATURE_HEIGHT}px`
)
return creature;
2024-08-10 00:15:49 -04:00
}
/**
* Create the creature and start its rendering
* @param {HTMLElement} container container element for creatures. the kennel if you will
* @param {string} name name of the creature
* @param {string} [spriteSheet] name of the sprite sheet. must be in /static/sprites
* uses default sprite sheet if undefined
* @param {number} [initialState] starting state of the creature
* @param {number} [initialPositionX] initial x position in pixels (from the left side)
* @param {number} [initialPositionY] initial y position in pixels (from the top)
* @return Creature
2024-08-10 00:15:49 -04:00
*/
const createCreature = (
container,
name,
spriteSheet,
initialState = CreatureState.IDLE,
initialPositionX = 0,
initialPositionY = 0
) => {
const creatureEle = document.createElement('div');
2024-08-10 00:15:49 -04:00
const spriteSheetUrl = spriteSheet
? `url('/static/sprites/${spriteSheet}')`
: `url('/static/sprites/defaults/${getRandomInt(1, DEFAULT_SPRITE_SHEET_COUNT)}.gif')`;
2024-08-10 00:15:49 -04:00
creatureEle.setAttribute('id', name);
creatureEle.style.setProperty('width', `${CREATURE_WIDTH}px`);
creatureEle.style.setProperty('height', `${CREATURE_HEIGHT}px`);
creatureEle.style.setProperty('position', 'fixed');
creatureEle.style.setProperty('image-rendering', 'pixelated');
creatureEle.style.setProperty('background-image', spriteSheetUrl);
creatureEle.style.setProperty('display', 'hidden');
2024-08-10 00:15:49 -04:00
container.appendChild(creatureEle);
2024-08-10 00:15:49 -04:00
return {
2024-08-10 00:15:49 -04:00
name,
spriteSheet,
state: initialState,
stateDuration: 0,
positionX: constrain(initialPositionX, 0, container.clientWidth),
positionY: constrain(initialPositionY, 0, container.clientHeight),
element: creatureEle,
2024-08-10 00:15:49 -04:00
container
}
}
/**
* Start creature animation
* @param {Creature} creature
* @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity
* (via {@link renderAndUpdateCreature})
*/
const beginCreatureAnimation = (
creature,
updateCreature = (creature) => ({ ... creature })
) => {
const timeoutCallback = (callbackCreature) => {
const newCreatureFrame = updateCreature(renderCreature(callbackCreature));
setTimeout(timeoutCallback, FRAME_DELAY, newCreatureFrame);
}
// render/update initial frame and start animation
setTimeout(timeoutCallback, FRAME_DELAY, updateCreature(renderCreature(creature)));
2024-08-10 00:15:49 -04:00
}
// === </CREATURE_UTILS> ===
/**
* 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<Object.<number, number>>}
* @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<Creature>} 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.<number, function(creature: Creature, allCreatures: Array<Creature>): 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 = Array(32).fill('test-creature')
.map((name, index) => createCreature(
kennelWindowEle,
name + index.toString(),
undefined, // default sprite sheet
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) => {
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
}