feat: implement state machine for creatures
This commit is contained in:
parent
b005f4b83a
commit
90a3446bdd
582
static/index.js
582
static/index.js
|
@ -5,6 +5,24 @@ const CREATURE_WIDTH = 32; // in px
|
||||||
const FRAME_RATE = 6; // in FPS
|
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 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
|
* Enum of creature states
|
||||||
|
@ -31,9 +49,20 @@ const CreatureState = Object.freeze({
|
||||||
WALK_NORTHWEST: 16,
|
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
|
* @typedef {[number, number]} SpriteFrameOffset the client of the sprite with respect to
|
||||||
* the left/top background position offset (off by factor of sprite size)
|
* the left/top background position client (off by factor of sprite size)
|
||||||
* @type {Object.<number, Array<SpriteFrameOffset>>}
|
* @type {Object.<number, Array<SpriteFrameOffset>>}
|
||||||
*/
|
*/
|
||||||
const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({
|
const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({
|
||||||
|
@ -109,6 +138,8 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({
|
||||||
|
|
||||||
// === <TYPES> ===
|
// === <TYPES> ===
|
||||||
|
|
||||||
|
/** @typedef {[number, number]} vec2 a vector 2 components */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properties for creatures running around the screen
|
* Properties for creatures running around the screen
|
||||||
* @typedef {Object} Creature
|
* @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 {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} 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} 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} positionX x component of the center of the creature position
|
||||||
* @property {number} positionY the number of pixels away from the top of the container element
|
* @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} 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 {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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// === <TYPES> ===
|
// === <TYPES> ===
|
||||||
|
@ -127,15 +159,26 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({
|
||||||
// === <MATH_UTILS> ===
|
// === <MATH_UTILS> ===
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* If max is less than or equal to min, return min
|
||||||
* @param {number} min inclusive lower bound
|
* @param {number} min inclusive lower bound
|
||||||
* @param {number} max exclusive upper bound
|
* @param {number} max exclusive upper bound
|
||||||
* @return {number} number in [min, max)
|
* @return {number} number in [min, max)
|
||||||
*/
|
*/
|
||||||
const randomInt = (min, max) => {
|
const getRandomInRange = (min, max) => {
|
||||||
if (max <= min) return min;
|
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)
|
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> ===
|
// === </MATH_UTILS> ===
|
||||||
|
|
||||||
// === <CREATURE_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
|
* Return a new updateCreature function that implicitly accepts the context
|
||||||
* of all other creatures being rendered.
|
* of all other creatures being rendered.
|
||||||
|
@ -194,8 +363,10 @@ const renderCreature = (creature) => {
|
||||||
creature.element.style.setProperty('display', 'block');
|
creature.element.style.setProperty('display', 'block');
|
||||||
|
|
||||||
// set position
|
// set position
|
||||||
creature.element.style.setProperty('left', `${creature.positionX}px`);
|
const positionX = creature.positionX - (0.5 * CREATURE_WIDTH) + creature.container.clientLeft
|
||||||
creature.element.style.setProperty('top', `${creature.positionY}px`);
|
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
|
// set sprite
|
||||||
const spriteFrames = CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES[creature.state]
|
const spriteFrames = CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES[creature.state]
|
||||||
|
@ -229,7 +400,7 @@ const createCreature = (
|
||||||
const creatureEle = document.createElement('div');
|
const creatureEle = document.createElement('div');
|
||||||
const spriteSheetUrl = spriteSheet
|
const spriteSheetUrl = spriteSheet
|
||||||
? `url('/static/sprites/${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.setAttribute('id', name);
|
||||||
creatureEle.style.setProperty('width', `${CREATURE_WIDTH}px`);
|
creatureEle.style.setProperty('width', `${CREATURE_WIDTH}px`);
|
||||||
|
@ -274,43 +445,378 @@ const beginCreatureAnimation = (
|
||||||
|
|
||||||
// === </CREATURE_UTILS> ===
|
// === </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 = () => {
|
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
|
// EXAMPLE SCRIPT
|
||||||
const kennelWindowEle = document.querySelector('#kennel-window');
|
const kennelWindowEle = document.querySelector('#kennel-window');
|
||||||
const creatures = ['test-creature-1', 'test-creature-2', 'test-creature-3']
|
const creatures = Array(32).fill('test-creature')
|
||||||
.map((name) => createCreature(
|
.map((name, index) => createCreature(
|
||||||
kennelWindowEle,
|
kennelWindowEle,
|
||||||
name,
|
name + index.toString(),
|
||||||
undefined, // default sprite sheet
|
undefined, // default sprite sheet
|
||||||
randomInt(0, 17), // random state
|
getRandomChoice(undefined, [
|
||||||
randomInt(0, 2) * kennelWindowEle.clientWidth, // randomly left or right side
|
CreatureState.IDLE,
|
||||||
randomInt(0, 2) * kennelWindowEle.clientHeight, // randomly top or bottom
|
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 updateCreature = updateCreatureWithAllCreaturesContext((creature, allCreatures) => {
|
||||||
// example update creature script to bring creatures to the centroid
|
const updatedCreature = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state](creature, allCreatures);
|
||||||
|
return updatedCreature
|
||||||
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,
|
|
||||||
}
|
|
||||||
}, creatures);
|
}, creatures);
|
||||||
|
|
||||||
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))
|
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.onresize = () => {
|
||||||
|
// TODO: dynamically change creature size based on window size
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
#kennel-window {
|
#kennel-window {
|
||||||
width: 80vw;
|
width: 100%;
|
||||||
min-width: 8rem;
|
height: 100vh;
|
||||||
height: 80vw;
|
background-color: whitesmoke;
|
||||||
min-height: 8rem;
|
margin: 0;
|
||||||
margin: auto;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
Loading…
Reference in New Issue