// === === const CREATURE_HEIGHT = 32; // in px 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 /** * 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, }) /** * @typedef {[number, number]} SpriteFrameOffset the offset of the sprite with respect to * the left/top background position offset (off by factor of sprite size) * @type {Object.>} */ 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], ], }); // === === // === === /** * 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 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 {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) */ // === === // === === /** * Returns 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) => { if (max <= min) return min; return Math.floor(Math.random() * (max - min) + min); } /** * 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 */ const normalize = (...components) => { const magnitude = Math.sqrt( components.reduce((squaredSum, component) => squaredSum + (component * component), 0) ) return components.map(component => component / magnitude) } // === === // === === /** * Return a new updateCreature function that implicitly accepts the context * of all other creatures being rendered. * @param {(creature: Creature, allCreatures?: Array) => Creature} updateCreature * updateCreature function that uses the context of all creatures * @param {Array} 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; } } /** * Render a frame of the creature and loads the next frame for render * @param {Creature} creature * @return {Creature} the creature being rendered */ 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`); // 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; } /** * 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 */ const createCreature = ( container, name, spriteSheet, initialState = CreatureState.IDLE, initialPositionX = 0, initialPositionY = 0 ) => { const creatureEle = document.createElement('div'); const spriteSheetUrl = spriteSheet ? `url('/static/sprites/${spriteSheet}')` : `url('/static/sprites/defaults/${randomInt(1, DEFAULT_SPRITE_SHEET_COUNT)}.gif')`; 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'); container.appendChild(creatureEle); return { name, spriteSheet, state: initialState, stateDuration: 0, positionX: constrain(initialPositionX, 0, container.clientWidth), positionY: constrain(initialPositionY, 0, container.clientHeight), element: creatureEle, 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))); } // === === window.onload = () => { // EXAMPLE SCRIPT const kennelWindowEle = document.querySelector('#kennel-window'); const creatures = ['test-creature-1', 'test-creature-2', 'test-creature-3'] .map((name) => createCreature( kennelWindowEle, name, 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 )) 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, } }, creatures); creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature)) }