diff --git a/pyproject.toml b/pyproject.toml index 84eb4e3..369a432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ select = [ ] # Note: Ruff supports a top-level `src` option in lieu of isort's `src_paths` setting. -src = ["fastapi_poetry_starter", "tests"] +src = ["kennel", "tests"] ignore = [] diff --git a/static/index.js b/static/index.js index e1c4d35..088f304 100644 --- a/static/index.js +++ b/static/index.js @@ -1,6 +1,8 @@ +// === === + const CREATURE_HEIGHT = 32; // in px const CREATURE_WIDTH = 32; // in px -const FRAME_RATE = 12; // in FPS +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 @@ -103,6 +105,10 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ ], }); +// === === + +// === === + /** * Properties for creatures running around the screen * @typedef {Object} Creature @@ -116,6 +122,10 @@ const CREATURE_STATE_TO_SPRITE_FRAME_OFFSET_INDICES = Object.freeze({ * @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 @@ -129,26 +139,63 @@ const randomInt = (min, max) => { } /** - * Return a creature's next frame based on the current creature frame - * @param {Creature} creature - * @return Creature the next frame of the creature + * 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 getNextCreatureFrame = (creature) => { - // TODO - return { ...creature, stateDuration: creature.stateDuration + 1 }; +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 next frame of the creature + * @return {Creature} the creature being rendered */ const renderCreature = (creature) => { + creature.element.style.setProperty('display', 'block'); + // set position - const positionX = Math.min(creature.positionX, creature.container.clientWidth); - const positionY = Math.min(creature.positionY, creature.container.clientHeight); - creature.element.style.setProperty('left', `${positionX}px`); - creature.element.style.setProperty('top', `${positionY}px`); + 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] @@ -157,9 +204,7 @@ const renderCreature = (creature) => { 'background-position', `${currentSpriteFrameOffset[0] * CREATURE_WIDTH}px ${currentSpriteFrameOffset[1] * CREATURE_HEIGHT}px` ) - - const nextCreatureFrame = getNextCreatureFrame(creature); - setTimeout(renderCreature, FRAME_DELAY, nextCreatureFrame) + return creature; } /** @@ -171,6 +216,7 @@ const renderCreature = (creature) => { * @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, @@ -180,38 +226,91 @@ const createCreature = ( initialPositionX = 0, initialPositionY = 0 ) => { - const creatureEl = document.createElement('div'); + const creatureEle = document.createElement('div'); const spriteSheetUrl = spriteSheet ? `url('/static/sprites/${spriteSheet}')` : `url('/static/sprites/defaults/${randomInt(1, DEFAULT_SPRITE_SHEET_COUNT)}.gif')`; - creatureEl.setAttribute('id', name); - creatureEl.style.setProperty('width', `${CREATURE_WIDTH}px`); - creatureEl.style.setProperty('height', `${CREATURE_HEIGHT}px`); - creatureEl.style.setProperty('position', 'fixed'); - creatureEl.style.setProperty('image-rendering', 'pixelated'); - creatureEl.style.setProperty('background-image', spriteSheetUrl); + 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(creatureEl); + container.appendChild(creatureEle); - renderCreature({ + return { name, spriteSheet, state: initialState, stateDuration: 0, - positionX: Math.max(0, initialPositionX), - positionY: Math.max(0, initialPositionY), - element: creatureEl, + positionX: constrain(initialPositionX, 0, container.clientWidth), + positionY: constrain(initialPositionY, 0, container.clientHeight), + element: creatureEle, container - }); + } } -window.onload = () => { - const kennelWindowEle = document.querySelector('#kennel-window'); - createCreature( - kennelWindowEle, - 'test-creature', - undefined, - CreatureState.SCRATCH_SELF - ) +/** + * 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)) } diff --git a/static/style.css b/static/style.css index f77e958..73c2486 100644 --- a/static/style.css +++ b/static/style.css @@ -3,5 +3,5 @@ min-width: 8rem; height: 80vw; min-height: 8rem; - margin: auto inherit; + margin: auto; } \ No newline at end of file