Compare commits
2 Commits
dev-fronte
...
main
Author | SHA1 | Date |
---|---|---|
Elizabeth Hunt | 6f223d2462 | |
Elizabeth Hunt | 9cdb7a145e |
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: run tests
|
||||||
|
image: python:3.12
|
||||||
|
commands:
|
||||||
|
- pip install poetry
|
||||||
|
- poetry install --with main,dev
|
||||||
|
- poetry run pytest
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: run tests
|
||||||
|
image: python:3.12
|
||||||
|
commands:
|
||||||
|
- pip install poetry
|
||||||
|
- poetry install --with main,dev
|
||||||
|
- poetry run pytest
|
||||||
|
- name: docker
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: gitea_packpub_username
|
||||||
|
password:
|
||||||
|
from_secret: gitea_packpub_password
|
||||||
|
registry: git.hatecomputers.club
|
||||||
|
repo: git.hatecomputers.club/hatecomputers/kennel
|
||||||
|
- name: ssh
|
||||||
|
image: appleboy/drone-ssh
|
||||||
|
settings:
|
||||||
|
host: hatecomputers.club
|
||||||
|
username: root
|
||||||
|
key:
|
||||||
|
from_secret: cd_ssh_key
|
||||||
|
port: 22
|
||||||
|
command_timeout: 2m
|
||||||
|
script:
|
||||||
|
- systemctl restart docker-compose@kennel
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
event:
|
||||||
|
- push
|
12
Dockerfile
12
Dockerfile
|
@ -5,8 +5,10 @@ COPY ./pyproject.toml ./poetry.lock* /tmp/
|
||||||
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes
|
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes
|
||||||
|
|
||||||
FROM python:3.12
|
FROM python:3.12
|
||||||
WORKDIR /code
|
WORKDIR /app
|
||||||
COPY --from=requirements-stage /tmp/requirements.txt /code/requirements.txt
|
COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
|
||||||
COPY kennel /code/src
|
COPY kennel /app/kennel
|
||||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80"]
|
COPY static /app/static
|
||||||
|
COPY templates /app/templates
|
||||||
|
CMD ["uvicorn", "kennel.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
|
@ -0,0 +1,17 @@
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
kennel:
|
||||||
|
restart: always
|
||||||
|
build: .
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "http://localhost:8000/healthcheck"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:60613:8000"
|
||||||
|
|
|
@ -5,7 +5,11 @@ from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI(
|
||||||
|
servers = [
|
||||||
|
{"url": "https://kennel.hatecomputers.club", "description": "prod"}
|
||||||
|
]
|
||||||
|
)
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,7 +44,7 @@ templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
@app.get("/healthcheck")
|
@app.get("/healthcheck")
|
||||||
async def healthcheck():
|
async def healthcheck():
|
||||||
return Response()
|
return Response("hello")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
922
static/index.js
922
static/index.js
|
@ -1,923 +1,5 @@
|
||||||
// === <CONSTANTS> ===
|
|
||||||
|
|
||||||
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
|
|
||||||
const WALK_SIZE = 8; // magnitude of position change, in px
|
|
||||||
const MAX_RETRIES = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* @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 client of the sprite with respect to
|
|
||||||
* the left/top background position client (off by factor of sprite size)
|
|
||||||
* @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 */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* @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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// === <TYPES> ===
|
|
||||||
|
|
||||||
// === <MATH_UTILS> ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 getRandomInRange = (min, max) => {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an array of n vec2's such that all vectors lie in
|
|
||||||
* [left, left + width) x [top, top + height) and are at least radius
|
|
||||||
* distance away from all other points.
|
|
||||||
* This function uses almost (but not quite)
|
|
||||||
* [the poisson disk sampling method]{@link https://a5huynh.github.io/posts/2019/poisson-disk-sampling/}
|
|
||||||
* insofar that instead of generating points in an annulus around a seed,
|
|
||||||
* it generates points anywhere else in the bounds.
|
|
||||||
* @param {number} n the number of points to generate
|
|
||||||
* @param {number} radius the minimum distance between points
|
|
||||||
* @param {number} left x lower bound of points
|
|
||||||
* @param {number} top y lower bound of points
|
|
||||||
* @param {number} width x width of bounds
|
|
||||||
* @param {number} height y width of bounds
|
|
||||||
* @param {number=} maxRetries maximum number of generation attemps before giving up
|
|
||||||
* @return {Array<vec2>}
|
|
||||||
*/
|
|
||||||
const generateBlueNoise = (n, radius, left, top, width, height, maxRetries = MAX_RETRIES) => {
|
|
||||||
const gridCellSize = radius / Math.sqrt(2);
|
|
||||||
const gridWidth = Math.ceil(width / gridCellSize);
|
|
||||||
const gridHeight = Math.ceil(height / gridCellSize);
|
|
||||||
const grid = /** @type {Array<Array<vec2|undefined>>} */ [...Array(gridHeight)].map(() => Array(gridWidth));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {vec2} point
|
|
||||||
* @return {vec2}
|
|
||||||
*/
|
|
||||||
const getSamplePoint = (point) => {
|
|
||||||
const randomX = Math.random() * (width - 2 * radius);
|
|
||||||
const randomY = Math.random() * (height - 2 * radius);
|
|
||||||
|
|
||||||
const samplePointX = left + (
|
|
||||||
randomX < (point.at(0) - radius) ? randomX : randomX + 2 * radius
|
|
||||||
)
|
|
||||||
const samplePointY = top + (
|
|
||||||
randomY < (point.at(1) - radius) ? randomY : randomY + 2 * radius
|
|
||||||
)
|
|
||||||
|
|
||||||
return [samplePointX, samplePointY];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {vec2} point
|
|
||||||
* @return {void}
|
|
||||||
*/
|
|
||||||
const insertPointIntoGrid = (point) => {
|
|
||||||
const indexX = Math.floor((point.at(0) - left) / gridCellSize);
|
|
||||||
const indexY = Math.floor((point.at(1) - top) / gridCellSize);
|
|
||||||
|
|
||||||
grid[indexY][indexX] = point;
|
|
||||||
}
|
|
||||||
|
|
||||||
const areValid = (point1, point2) => {
|
|
||||||
const deltaX = point1.at(0) - point2.at(0);
|
|
||||||
const deltaY = point1.at(1) - point2.at(1);
|
|
||||||
return (deltaX * deltaX + deltaY * deltaY) > (radius * radius)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {vec2} point
|
|
||||||
* @return {boolean}
|
|
||||||
*/
|
|
||||||
const isValidPoint = (point) => {
|
|
||||||
const indexX = Math.floor((point.at(0) - left) / gridCellSize);
|
|
||||||
const indexY = Math.floor((point.at(1) - top) / gridCellSize);
|
|
||||||
|
|
||||||
const testPoints = [
|
|
||||||
[indexX - 1, indexY - 1], [indexX, indexY - 1], [indexX + 1, indexY - 1],
|
|
||||||
[indexX - 1, indexY], [indexX, indexY], [indexX + 1, indexY],
|
|
||||||
[indexX - 1, indexY + 1], [indexX, indexY + 1], [indexX + 1, indexY + 1],
|
|
||||||
]
|
|
||||||
.filter((indices) => (
|
|
||||||
indices.at(0) >= 0 && indices.at(1) >= 0 && indices.at(0) < gridWidth && indices.at(1) < gridHeight
|
|
||||||
))
|
|
||||||
.map((indices) => grid[indices.at(1)][indices.at(0)])
|
|
||||||
.filter((point) => point !== undefined);
|
|
||||||
|
|
||||||
return testPoints.every((testPoint) => areValid(point, testPoint))
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize
|
|
||||||
const initialPoint = [
|
|
||||||
left + width / 2, top + height / 2,
|
|
||||||
]
|
|
||||||
insertPointIntoGrid(initialPoint);
|
|
||||||
const blueNoiseSamples = [initialPoint];
|
|
||||||
const activeList = [initialPoint];
|
|
||||||
|
|
||||||
const findAndInsertPoint = () => {
|
|
||||||
if (activeList.length === 0) return;
|
|
||||||
|
|
||||||
const activePoint = /** @type {vec2} */ getRandomChoice(undefined, activeList);
|
|
||||||
const samplePoints = [...Array(MAX_RETRIES)].map(_ => getSamplePoint(activePoint));
|
|
||||||
const samplePoint = samplePoints.find(isValidPoint);
|
|
||||||
|
|
||||||
if (samplePoint === undefined) {
|
|
||||||
// remove active point from active list
|
|
||||||
const indexOfActivePoint = activeList.indexOf(activePoint);
|
|
||||||
activeList.splice(indexOfActivePoint, 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeList.push(samplePoint);
|
|
||||||
blueNoiseSamples.push(samplePoint);
|
|
||||||
insertPointIntoGrid(samplePoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (activeList.length > 0 && blueNoiseSamples.length < n) {
|
|
||||||
findAndInsertPoint();
|
|
||||||
}
|
|
||||||
|
|
||||||
return blueNoiseSamples.length < n ? [] : blueNoiseSamples;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === </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 * 7/8, -Math.PI * 5/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 updateCreatureWithContext = (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
|
|
||||||
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]
|
|
||||||
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/${getRandomInt(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
|
|
||||||
*/
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// === </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 / 2,
|
|
||||||
creature2.positionX, creature2.positionY, CREATURE_WIDTH / 2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) => {
|
|
||||||
// always play for at least 8 frames
|
|
||||||
if (creature.stateDuration < 8 || !rollForNatN(10)) {
|
|
||||||
return {
|
|
||||||
...creature,
|
|
||||||
positionX: creature.positionX + (creature.walkDirection?.at(0) ?? 0) * WALK_SIZE,
|
|
||||||
positionY: creature.positionY + (creature.walkDirection?.at(1) ?? 0) * WALK_SIZE,
|
|
||||||
stateDuration: creature.stateDuration + 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// move away from groups of 2 or more creatures
|
|
||||||
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, [
|
|
||||||
2 * creature.positionX - centroidX,
|
|
||||||
2 * creature.positionY - centroidY,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there's a creature in the view radius, move toward it 25% of the time
|
|
||||||
if (spottedCreatures.length > 0 && rollForNatN(4)) {
|
|
||||||
const spottedCreature = /** @type {Creature} */ getRandomChoice(undefined, spottedCreatures);
|
|
||||||
return startCreatureWalkTowardDirection(creature, [
|
|
||||||
spottedCreature.positionX, spottedCreature.positionY,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.6
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (newState === CreatureState.IDLE || newState === undefined) {
|
|
||||||
return { ...creature, stateDuration: creature.stateDuration + 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newState !== CreatureState.WALK_NORTH) {
|
|
||||||
return {
|
|
||||||
...creature,
|
|
||||||
stateDuration: 0,
|
|
||||||
state: newState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transition to walking to random point
|
|
||||||
return startCreatureWalkTowardDirection(creature, [
|
|
||||||
getRandomInRange(creature.container.clientLeft, creature.container.clientLeft + creature.container.clientWidth),
|
|
||||||
getRandomInRange(creature.container.clientTop, creature.container.clientTop + creature.container.clientHeight),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
[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 random direction
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve collisions between the updated creature, the container bounds, and the other creatures.
|
|
||||||
* If the updated creature collides against the wall, move to a scratch direction state.
|
|
||||||
* If the updated creature collides with another creature, move to alert state.
|
|
||||||
* @param {Creature} updatedCreature the creature post update
|
|
||||||
* @param {Array<Creature>} allCreatures all creatures in kennel
|
|
||||||
* @param {Creature} creature previous creature state
|
|
||||||
* @return {Creature}
|
|
||||||
*/
|
|
||||||
const resolveCollision = (updatedCreature, allCreatures, creature) => {
|
|
||||||
// resolve creature collision
|
|
||||||
const collidedCreatures = allCreatures
|
|
||||||
.filter((otherCreature) => isCreaturesColliding(otherCreature, updatedCreature))
|
|
||||||
.filter((collidingCreature) => collidingCreature.name !== updatedCreature.name)
|
|
||||||
|
|
||||||
const collidedCreature = collidedCreatures.length > 0
|
|
||||||
? getRandomChoice(undefined, collidedCreatures)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// one third of the time, try to run away from the collided creature
|
|
||||||
if (collidedCreature !== undefined && rollForNatN(3)) {
|
|
||||||
const collidedCreature = getRandomChoice(undefined, collidedCreatures);
|
|
||||||
return resolveCollision(
|
|
||||||
startCreatureWalkTowardDirection(creature, [
|
|
||||||
creature.positionX - collidedCreature.positionX,
|
|
||||||
creature.positionY - collidedCreature.positionY,
|
|
||||||
]),
|
|
||||||
allCreatures,
|
|
||||||
creature,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (collidedCreature !== undefined) {
|
|
||||||
return {
|
|
||||||
...creature,
|
|
||||||
state: getRandomChoice(undefined, [CreatureState.ALERT, CreatureState.IDLE, CreatureState.TIRED]),
|
|
||||||
stateDuration: 0,
|
|
||||||
walkDirection: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolve wall collision
|
|
||||||
const wallCollisionDirection = isCreatureOutOfBounds(updatedCreature);
|
|
||||||
|
|
||||||
if (wallCollisionDirection !== undefined) {
|
|
||||||
return {
|
|
||||||
...updatedCreature,
|
|
||||||
state: WALL_DIRECTION_TO_SCRATCH_STATE[wallCollisionDirection] ?? CreatureState.IDLE,
|
|
||||||
walkDirection: undefined,
|
|
||||||
stateDuration: 0,
|
|
||||||
positionX: constrain(
|
|
||||||
updatedCreature.positionX,
|
|
||||||
creature.container.clientLeft + CREATURE_WIDTH / 2,
|
|
||||||
creature.container.clientLeft + creature.container.clientWidth - CREATURE_WIDTH / 2,
|
|
||||||
),
|
|
||||||
positionY: constrain(
|
|
||||||
updatedCreature.positionY,
|
|
||||||
creature.container.clientTop + CREATURE_HEIGHT / 2,
|
|
||||||
creature.container.clientTop + creature.container.clientHeight - CREATURE_HEIGHT / 2,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...updatedCreature };
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
// user preference for low motion. do not render creatures
|
console.log('from js');
|
||||||
if (window.matchMedia(`(prefers-reduced-motion: reduce)`).matches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEST_CREATURE_COUNT = 32;
|
|
||||||
const kennelWindowEle = document.querySelector('#kennel-window');
|
const kennelWindowEle = document.querySelector('#kennel-window');
|
||||||
|
kennelWindowEle.innerHTML = 'rendered from static/index.js';
|
||||||
const startingPoints = generateBlueNoise(
|
|
||||||
TEST_CREATURE_COUNT, CREATURE_WIDTH,
|
|
||||||
kennelWindowEle.clientLeft, kennelWindowEle.clientTop,
|
|
||||||
kennelWindowEle.clientWidth, kennelWindowEle.clientHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
const creatures = /** @type {Array<Creature>} */ startingPoints
|
|
||||||
.map((startingPoint, index) => createCreature(
|
|
||||||
kennelWindowEle,
|
|
||||||
`test-creature-${index}`,
|
|
||||||
undefined, // use a default sprite sheet
|
|
||||||
CreatureState.IDLE,
|
|
||||||
startingPoint.at(0),
|
|
||||||
startingPoint.at(1),
|
|
||||||
))
|
|
||||||
|
|
||||||
const updateCreature = updateCreatureWithContext((creature, allCreatures) => {
|
|
||||||
const updateCreatureFunction = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state];
|
|
||||||
const updatedCreature = updateCreatureFunction(creature, allCreatures);
|
|
||||||
return resolveCollision(updatedCreature, allCreatures, creature);
|
|
||||||
}, creatures);
|
|
||||||
|
|
||||||
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
// TODO: dynamically change creature size based on window size
|
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 6.4 KiB |
|
@ -1,12 +0,0 @@
|
||||||
#kennel-window {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: whitesmoke;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
|
@ -4,7 +4,6 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
|
|
||||||
<title>Kennel Club</title>
|
<title>Kennel Club</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
@ -10,13 +10,9 @@ client = TestClient(app)
|
||||||
def test_healthcheck():
|
def test_healthcheck():
|
||||||
response = client.get("/healthcheck")
|
response = client.get("/healthcheck")
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
assert response.text == ""
|
assert response.text == "hello"
|
||||||
|
|
||||||
|
def test_main():
|
||||||
def test_read_main():
|
|
||||||
# Example of testing logging using a context manager
|
|
||||||
with capture_logs() as cap_logs:
|
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert {"event": "In root path", "log_level": "info"} in cap_logs
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
assert response.json() == {"msg": "Hello World"}
|
assert response.text.startswith("<!DOCTYPE html>")
|
||||||
|
|
Loading…
Reference in New Issue