p5play
Version:
A JavaScript game engine that uses p5.js for graphics and Box2D for physics.
1,949 lines (1,770 loc) • 279 kB
JavaScript
/**
* p5play
* @version 3.26
* @author quinton-ashley
*/
if (typeof planck != 'object') {
if (typeof process == 'object') {
global.planck = require('./planck.min.js');
} else throw 'planck.js must be loaded before p5play';
}
p5.prototype.registerMethod('init', function p5playInit() {
const $ = this; // the p5 or q5 instance that called p5playInit
const pl = planck;
// Google Analytics collects anonymous usage data to help make p5play better.
// To opt out, set window._p5play_gtagged to false before loading p5play.
if (
typeof process != 'object' && // don't track in node.js
window._p5play_gtagged != false
) {
let script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-EHXNCTSYLK';
script.async = true;
document.head.append(script);
window._p5play_gtagged = true;
script.onload = () => {
window.dataLayer ??= [];
window.gtag = function () {
dataLayer.push(arguments);
};
gtag('js', new Date());
gtag('config', 'G-EHXNCTSYLK');
gtag('event', 'p5play_v3_26');
};
}
// in p5play the default angle mode is degrees
const DEGREES = $.DEGREES;
$.angleMode(DEGREES);
// scale to planck coordinates from p5 coordinates
const scaleTo = (x, y, tileSize) =>
new pl.Vec2((x * tileSize) / $.world.meterSize, (y * tileSize) / $.world.meterSize);
const scaleXTo = (x, tileSize) => (x * tileSize) / $.world.meterSize;
// scale from planck coordinates to p5 coordinates
const scaleFrom = (x, y, tileSize) =>
new pl.Vec2((x / tileSize) * $.world.meterSize, (y / tileSize) * $.world.meterSize);
const scaleXFrom = (x, tileSize) => (x / tileSize) * $.world.meterSize;
const linearSlop = pl.Settings.linearSlop;
const angularSlop = pl.Settings.angularSlop / 60;
const isSlop = (val) => Math.abs(val) <= linearSlop;
const fixRound = (val, slop) => (Math.abs(val - Math.round(val)) <= (slop || linearSlop) ? Math.round(val) : val);
const minAngleDist = (ang, rot) => {
let full = $._angleMode == DEGREES ? 360 : $.TWO_PI;
let dist1 = (ang - rot) % full;
let dist2 = (full - Math.abs(dist1)) * -Math.sign(dist1);
return (Math.abs(dist1) < Math.abs(dist2) ? dist1 : dist2) || 0;
};
const eventTypes = {
_collisions: ['_collides', '_colliding', '_collided'],
_overlappers: ['_overlaps', '_overlapping', '_overlapped']
};
/**
* @class
*/
this.P5Play = class {
/**
* This class is deleted after it's used
* to create the `p5play` object
* which contains information about the sketch.
*/
constructor() {
/**
* Contains all the sprites in the sketch,
* but users should use the `allSprites` group.
*
* The keys are the sprite's unique ids.
* @type {Object.<number, Sprite>}
*/
this.sprites = {};
/**
* Contains all the groups in the sketch,
*
* The keys are the group's unique ids.
* @type {Object.<number, Group>}
*/
this.groups = {};
this.groupsCreated = 0;
this.spritesCreated = 0;
this.spritesDrawn = 0;
/**
* Cache for loaded images.
*/
this.images = {};
/**
* Used for debugging, set to true to make p5play
* not load any images.
* @type {Boolean}
* @default false
*/
this.disableImages = false;
/**
* The default color palette, at index 0 of this array,
* has all the letters of the English alphabet mapped to colors.
* @type {Array}
*/
this.palettes = [];
/**
* Emoji scale factor, used when making emoji images.
* @type {Number}
* @default 1
*/
this.emojiScale = 1;
/**
* Friendly rounding eliminates some floating point errors.
* @type {Boolean}
* @default true
*/
this.friendlyRounding = true;
/**
* Groups that are removed using `group.remove()` are not
* fully deleted from `p5play.groups` by default, so their data
* is still accessible. Set to false to permanently delete
* removed groups, which reduces memory usage.
* @type {Boolean}
* @default true
*/
this.storeRemovedGroupRefs = true;
/**
* Snaps sprites to the nearest `p5play.gridSize`
* increment when they are moved.
* @type {Boolean}
* @default false
*/
this.snapToGrid = false;
/**
* The size of the grid cells that sprites are snapped to.
* @type {Number}
* @default 0.5
*/
this.gridSize = 0.5;
/**
* Information about the operating system being used to run
* p5play, retrieved from the `navigator` object.
*/
this.os = {};
this.context = 'web';
if (window.matchMedia) this.hasMouse = window.matchMedia('(any-hover: none)').matches ? false : true;
else this.hasMouse = true;
this.standardizeKeyboard = false;
if (typeof navigator == 'object') {
let idx = navigator.userAgent.indexOf('iPhone OS');
if (idx > -1) {
let version = navigator.userAgent.substring(idx + 10, idx + 12);
this.os.platform = 'iOS';
this.os.version = version;
} else {
let pl = navigator.userAgentData?.platform;
if (!pl && navigator.platform) {
pl = navigator.platform.slice(3);
if (pl == 'Mac') pl = 'macOS';
else if (pl == 'Win') pl = 'Windows';
else if (pl == 'Lin') pl = 'Linux';
}
this.os.platform = pl;
}
}
/**
* Displays the number of sprites drawn, the current FPS
* as well as the average, minimum, and maximum FPS achieved
* during the previous second.
*
* FPS in this context refers to how many frames per second your
* computer can generate, based on the physics calculations and any
* other processes necessary to generate a frame, but not
* including the delay between when frames are actually shown on
* the screen. The higher the FPS, the better your game is
* performing.
*
* You can use this function for approximate performance testing.
* But for the most accurate results, use your web browser's
* performance testing tools.
*
* Generally having less sprites and using a smaller canvas will
* make your game perform better. Also drawing images is faster
* than drawing shapes.
* @type {Boolean}
* @default false
*/
this.renderStats = false;
this._renderStats = {
x: 10,
y: 20,
font: 'monospace'
};
this._fps = 60;
this._fpsArr = [60];
/*
* Ledgers for collision callback functions.
*
* Doing this:
* group1.collides(group2, cb1);
* sprite0.collides(sprite1, cb0);
*
* Would result in this:
* p5play._collides = {
* 1: {
* 2: cb1
* },
* 1000: {
* 2: cb1,
* 1001: cb0
* }
* };
*/
this._collides = {};
this._colliding = {};
this._collided = {};
/*
* Ledgers for overlap callback functions.
*/
this._overlaps = {};
this._overlapping = {};
this._overlapped = {};
}
/**
* This function is called when an image is loaded. By default it
* does nothing, but it can be overridden.
*/
onImageLoad() {}
};
/**
* Contains information about the sketch.
* @type {P5Play}
*/
this.p5play = new $.P5Play();
delete $.P5Play;
let usePhysics = true;
let timeScale = 1;
let log = ($.log = console.log);
$.DYN = $.DYNAMIC = 'dynamic';
$.STA = $.STATIC = 'static';
$.KIN = $.KINEMATIC = 'kinematic';
/**
* @class
*/
this.Sprite = class {
/**
* <a href="https://p5play.org/learn/sprite.html">
* Look at the Sprite reference pages before reading these docs.
* </a>
*
* The Sprite constructor can be used in many different ways.
*
* In fact it's so flexible that I've only listed out some of the
* most common ways it can be used in the examples section below.
* Try experimenting with it! It's likely to work the way you
* expect it to, if not you'll just get an error.
*
* Special feature! If the first parameter to this constructor is a
* loaded Image, Ani, or name of a animation,
* then the Sprite will be created with that animation. If the
* dimensions of the sprite are not given, then the Sprite will be
* created using the dimensions of the animation.
*
* Every sprite you create is added to the `allSprites`
* group and put on the top draw order layer, in front of all
* previously created sprites.
*
* @param {Number} [x] - horizontal position of the sprite
* @param {Number} [y] - vertical position of the sprite
* @param {Number} [w] - width of the placeholder rectangle and of
* the collider until an image or new collider are set. *OR* If height is not
* set then this parameter becomes the diameter of the placeholder circle.
* @param {Number} [h] - height of the placeholder rectangle and of the collider
* until an image or new collider are set
* @param {String} [collider] - collider type is 'dynamic' by default, can be
* 'static', 'kinematic', or 'none'
* @example
*
* let spr = new Sprite();
*
* let rectangle = new Sprite(x, y, width, height);
*
* let circle = new Sprite(x, y, diameter);
*
* let spr = new Sprite(aniName, x, y);
*
* let line = new Sprite(x, y, [length, angle]);
*/
constructor(x, y, w, h, collider) {
// using boolean flags is faster than instanceof checks
this._isSprite = true;
/**
* Each sprite has a unique id number. Don't change it!
* They are useful for debugging.
* @type {Number}
*/
this.idNum;
// id num is not set until the input params are validated
let args = [...arguments];
let group, ani;
// first arg was a group to add the sprite to
// used internally by the GroupSprite class
if (args[0] !== undefined && args[0]._isGroup) {
group = args[0];
args = args.slice(1);
}
// first arg is a Ani, animation name, or Image
if (
args[0] !== undefined &&
(typeof args[0] == 'string' || args[0] instanceof $.Ani || args[0] instanceof p5.Image)
) {
// shift
ani = args[0];
args = args.slice(1);
}
// invalid
if (args.length == 1 && typeof args[0] == 'number') {
throw new FriendlyError('Sprite', 0, [args[0]]);
}
if (!Array.isArray(args[0])) {
// valid use for creating a box collider:
// new Sprite(x, y, w, h, colliderType)
x = args[0];
y = args[1];
w = args[2];
h = args[3];
collider = args[4];
} else {
// valid use for creating chain/polygon using vertex mode:
// new Sprite([[x1, y1], [x2, y2], ...], colliderType)
x = undefined;
y = undefined;
w = args[0];
h = undefined;
collider = args[1];
if (Array.isArray(collider)) {
throw new FriendlyError('Sprite', 1, [`[[${w}], [${h}]]`]);
}
}
// valid use without setting size:
// new Sprite(x, y, colliderType)
if (typeof w == 'string') {
collider = w;
w = undefined;
}
if (typeof h == 'string') {
if (isColliderType(h)) {
// valid use to create a circle:
// new Sprite(x, y, d, colliderType)
collider = h;
} else {
// valid use to create a regular polygon:
// new Sprite(x, y, sideLength, polygonName)
w = getRegularPolygon(w, h);
}
h = undefined;
}
this.idNum = $.p5play.spritesCreated;
this._uid = 1000 + this.idNum;
$.p5play.sprites[this._uid] = this;
$.p5play.spritesCreated++;
/**
* Groups the sprite belongs to, including allSprites
* @type {Group[]}
* @default [allSprites]
*/
this.groups = [];
/**
* Keys are the animation label, values are Ani objects.
* @type {Anis}
*/
this.animations = new $.Anis();
/**
* Joints that the sprite is attached to
* @type {Joint[]}
* @default []
*/
this.joints = [];
this.joints.removeAll = () => {
while (this.joints.length) {
this.joints.at(-1).remove();
}
};
/**
* If set to true, p5play will record all changes to the sprite's
* properties in its `mod` array. Intended to be used to enable
* online multiplayer.
* @type {Boolean}
* @default undefined
*/
this.watch;
/**
* An Object that has sprite property number codes as keys,
* these correspond to the index of the property in the
* Sprite.props array. The booleans values this object stores,
* indicate which properties were changed since the last frame.
* Useful for limiting the amount of sprite data sent in binary
* netcode to only the sprite properties that have been modified.
* @type {Object}
*/
this.mod = {};
this._removed = false;
this._life = 2147483647;
this._visible = true;
this._pixelPerfect = false;
this._aniChangeCount = 0;
this._draw = () => this.__draw();
this._hasOverlap = {};
this._collisions = {};
this._overlappers = {};
group ??= $.allSprites;
this._tile = '';
this.tileSize = group.tileSize || 1;
let _this = this;
// this.x and this.y are getters and setters that change this._pos internally
// this.pos and this.position get this._position
this._position = {
x: 0,
y: 0
};
this._pos = $.createVector.call($);
Object.defineProperty(this._pos, 'x', {
get() {
if (!_this.body || !usePhysics) return _this._position.x;
let x = (_this.body.getPosition().x / _this.tileSize) * $.world.meterSize;
return $.p5play.friendlyRounding ? fixRound(x) : x;
},
set(val) {
_this._position.x = val;
if (_this.body) {
let pos = new pl.Vec2((val * _this.tileSize) / $.world.meterSize, _this.body.getPosition().y);
_this.body.setPosition(pos);
}
}
});
Object.defineProperty(this._pos, 'y', {
get() {
if (!_this.body || !usePhysics) return _this._position.y;
let y = (_this.body.getPosition().y / _this.tileSize) * $.world.meterSize;
return $.p5play.friendlyRounding ? fixRound(y) : y;
},
set(val) {
_this._position.y = val;
if (_this.body) {
let pos = new pl.Vec2(_this.body.getPosition().x, (val * _this.tileSize) / $.world.meterSize);
_this.body.setPosition(pos);
}
}
});
this._canvasPos = $.createVector.call($);
Object.defineProperty(this._canvasPos, 'x', {
get() {
let x = _this._pos.x - $.camera.x;
if ($.canvas.renderer == 'c2d') x += $.canvas.hw / $.camera._zoom;
return x;
}
});
Object.defineProperty(this._canvasPos, 'y', {
get() {
let y = _this._pos.y - $.camera.y;
if ($.canvas.renderer == 'c2d') y += $.canvas.hh / $.camera._zoom;
return y;
}
});
// used by this._vel if the Sprite has no physics body
this._velocity = {
x: 0,
y: 0
};
this._direction = 0;
this._vel = $.createVector.call($);
Object.defineProperties(this._vel, {
x: {
get() {
let val;
if (_this.body) val = _this.body.getLinearVelocity().x;
else val = _this._velocity.x;
val /= _this.tileSize;
return $.p5play.friendlyRounding ? fixRound(val) : val;
},
set(val) {
val *= _this.tileSize;
if (_this.body) {
_this.body.setLinearVelocity(new pl.Vec2(val, _this.body.getLinearVelocity().y));
} else {
_this._velocity.x = val;
}
if (val || this.y) _this._direction = this.heading();
}
},
y: {
get() {
let val;
if (_this.body) val = _this.body.getLinearVelocity().y;
else val = _this._velocity.y;
val /= _this.tileSize;
return $.p5play.friendlyRounding ? fixRound(val) : val;
},
set(val) {
val *= _this.tileSize;
if (_this.body) {
_this.body.setLinearVelocity(new pl.Vec2(_this.body.getLinearVelocity().x, val));
} else {
_this._velocity.y = val;
}
if (val || this.x) _this._direction = this.heading();
}
}
});
this._mirror = {
_x: 1,
_y: 1,
get x() {
return this._x < 0;
},
set x(val) {
if (_this.watch) _this.mod[20] = true;
this._x = val ? -1 : 1;
},
get y() {
return this._y < 0;
},
set y(val) {
if (_this.watch) _this.mod[20] = true;
this._y = val ? -1 : 1;
}
};
this._heading = 'right';
this._layer = group._layer;
this._layer ??= $.allSprites._getTopLayer() + 1;
if (group.dynamic) collider ??= 'dynamic';
if (group.kinematic) collider ??= 'kinematic';
if (group.static) collider ??= 'static';
collider ??= group.collider;
if (!collider || typeof collider != 'string') {
collider = 'dynamic';
}
this.collider = collider;
x ??= group.x;
if (x === undefined) {
if ($.canvas?.renderer == 'c2d' && !$._webgpuFallback) {
x = $.canvas.hw / this.tileSize;
} else x = 0;
if (w) this._vertexMode = true;
}
y ??= group.y;
if (y === undefined) {
if ($.canvas?.renderer == 'c2d' && !$._webgpuFallback) {
y = $.canvas.hh / this.tileSize;
} else y = 0;
}
let forcedBoxShape = false;
if (w === undefined) {
w = group.w || group.width || group.d || group.diameter || group.v || group.vertices;
if (!h && !group.d && !group.diameter) {
h = group.h || group.height;
forcedBoxShape = true;
}
}
if (typeof x == 'function') x = x(group.length);
if (typeof y == 'function') y = y(group.length);
if (typeof w == 'function') w = w(group.length);
if (typeof h == 'function') h = h(group.length);
this.x = x;
this.y = y;
if (!group._isAllSpritesGroup) {
if (!ani) {
for (let _ani in group.animations) {
ani = _ani;
break;
}
if (!ani) {
ani = group._img;
if (typeof ani == 'function') {
ani = ani(group.length);
}
if (ani) this._img = true;
}
}
}
// temporarily add all the groups the sprite belongs to,
// since the next section of code could potentially load an
// animation from one of the sprite's groups
for (let g = group; g; g = $.p5play.groups[g.parent]) {
this.groups.push(g);
}
this.groups.reverse();
if (ani) {
let ts = this.tileSize;
if (this._img || ani instanceof p5.Image) {
if (typeof ani != 'string') this.image = ani;
else this.image = new $.EmojiImage(ani, w);
if (!w && (this._img.w != 1 || this._img.h != 1)) {
w = (this._img.defaultWidth || this._img.w) / ts;
h ??= (this._img.defaultHeight || this._img.h) / ts;
}
} else {
if (typeof ani == 'string') this._changeAni(ani);
else this._ani = ani.clone();
if (!w && (this._ani.w != 1 || this._ani.h != 1)) {
w = (this._ani.defaultWidth || this._ani.w) / ts;
h ??= (this._ani.defaultHeight || this._ani.h) / ts;
}
}
}
// make groups list empty, the sprite will be "officially" added
// to its groups after its collider is potentially created
this.groups = [];
/**
* Used to detect mouse events with the sprite.
* @type {_SpriteMouse}
*/
this.mouse = new $._SpriteMouse();
this._rotation = 0;
this._rotationSpeed = 0;
this._bearing = 0;
this._scale = new Scale();
Object.defineProperty(this._scale, 'x', {
get() {
return this._x;
},
set(val) {
if (val == this._x) return;
if (_this.watch) _this.mod[26] = true;
let scalarX = Math.abs(val / this._x);
_this._w *= scalarX;
_this._hw *= scalarX;
_this._resizeColliders({ x: scalarX, y: 1 });
this._x = val;
this._avg = (this._x + this._y) * 0.5;
}
});
Object.defineProperty(this._scale, 'y', {
get() {
return this._y;
},
set(val) {
if (val == this._y) return;
if (_this.watch) _this.mod[26] = true;
let scalarY = Math.abs(val / this._y);
if (_this._h) {
this._h *= scalarY;
this._hh *= scalarY;
}
_this._resizeColliders({ x: 1, y: scalarY });
this._y = val;
this._avg = (this._x + this._y) * 0.5;
}
});
this._offset = {
_x: 0,
_y: 0,
get x() {
return this._x;
},
set x(val) {
if (val == this._x) return;
if (_this.watch) _this.mod[21] = true;
_this._offsetCenterBy(val - this._x, 0);
},
get y() {
return this._y;
},
set y(val) {
if (val == this._y) return;
if (_this.watch) _this.mod[21] = true;
_this._offsetCenterBy(0, val - this._y);
}
};
this._massUndef = true;
if (w === undefined) {
this._dimensionsUndef = true;
this._widthUndef = true;
w = this.tileSize > 1 ? 1 : 50;
if (h === undefined) this._heightUndef = true;
}
if (forcedBoxShape) h ??= this.tileSize > 1 ? 1 : 50;
this._shape = group.shape;
// if collider is not "none"
if (this.__collider != 3) {
if (this._vertexMode) this.addCollider(w);
else this.addCollider(0, 0, w, h);
this.shape = this._shape;
} else {
this.w = w;
if (Array.isArray(w)) {
throw new Error(
'Cannot set the collider type of a sprite with a polygon or chain shape to "none". To achieve the same effect, use .overlaps(allSprites) to have your sprite overlap with the allSprites group.'
);
}
if (w !== undefined && h === undefined) this.shape = 'circle';
else {
this.shape = 'box';
this.h = h;
}
}
/**
* The sprite's position on the previous frame.
* @type {object}
*/
this.prevPos = { x, y };
this.prevRotation = 0;
this._dest = { x, y };
this._destIdx = 0;
this._debug = false;
/**
* Text displayed at the center of the sprite.
* @type {String}
* @default undefined
*/
this.text;
if (!group._isAllSpritesGroup) $.allSprites.push(this);
group.push(this);
let gvx = group.vel.x || 0;
let gvy = group.vel.y || 0;
if (typeof gvx == 'function') gvx = gvx(group.length - 1);
if (typeof gvy == 'function') gvy = gvy(group.length - 1);
this.vel.x = gvx;
this.vel.y = gvy;
// skip these properties
let skipProps = [
'ani',
'collider',
'x',
'y',
'w',
'h',
'd',
'diameter',
'dynamic',
'height',
'kinematic',
'static',
'vel',
'width'
];
// inherit properties from group in the order they were added
// skip props that were already set above
for (let prop of $.Sprite.propsAll) {
if (skipProps.includes(prop)) continue;
let val = group[prop];
if (val === undefined) continue;
if (typeof val == 'function' && isArrowFunction(val)) {
val = val(group.length - 1);
}
if (typeof val == 'object') {
if (val instanceof p5.Color) {
this[prop] = $.color(...val.levels);
} else {
this[prop] = Object.assign({}, val);
}
} else {
this[prop] = val;
}
}
skipProps = [
'add',
'animation',
'animations',
'autoCull',
'contains',
'GroupSprite',
'Group',
'idNum',
'length',
'mod',
'mouse',
'p',
'parent',
'Sprite',
'Subgroup',
'subgroups',
'velocity'
];
for (let i = 0; i < this.groups.length; i++) {
let g = this.groups[i];
let props = Object.keys(g);
for (let prop of props) {
if (!isNaN(prop) || prop[0] == '_' || skipProps.includes(prop) || $.Sprite.propsAll.includes(prop)) {
continue;
}
let val = g[prop];
if (val === undefined) continue;
if (typeof val == 'function' && isArrowFunction(val)) {
val = val(g.length - 1);
}
if (typeof val == 'object') {
this[prop] = Object.assign({}, val);
} else {
this[prop] = val;
}
}
}
{
let r = $.random(0.12, 0.96);
let g = $.random(0.12, 0.96);
let b = $.random(0.12, 0.96);
if ($._colorFormat != 1) {
r *= 255;
g *= 255;
b *= 255;
}
// "random" color that's not too dark or too light
this.color ??= $.color(r, g, b);
}
this._textFill ??= $.color(0);
this._textSize ??= this.tileSize == 1 ? ($.canvas ? $.textSize() : 12) : 0.8;
}
/**
* Adds a collider (fixture) to the sprite's physics body.
*
* It accepts parameters in a similar format to the Sprite
* constructor except the first two parameters are x and y offsets,
* the distance new collider should be from the center of the sprite.
*
* This function also recalculates the sprite's mass based on the
* size of the new collider added to it. However, it does not move
* the sprite's center of mass, which makes adding multiple colliders
* to a sprite easier.
*
* For better physics simulation results, run the `resetCenterOfMass`
* function after you finish adding colliders to a sprite.
*
* One limitation of the current implementation is that sprites
* with multiple colliders can't have their collider
* type changed without losing every collider added to the
* sprite besides the first.
*
* @param {Number} offsetX - distance from the center of the sprite
* @param {Number} offsetY - distance from the center of the sprite
* @param {Number} w - width of the collider
* @param {Number} h - height of the collider
*/
addCollider(offsetX, offsetY, w, h) {
if (this._removed) {
console.error("Can't add colliders to a sprite that was removed.");
return;
}
if (this.__collider == 3) {
this._collider = 'dynamic';
this.__collider = 0;
}
let props = {};
props.shape = this._parseShape(...arguments);
if (props.shape.m_type == 'chain') {
props.density = 0;
props.restitution = 0;
}
props.density ??= this.density || 5;
props.friction ??= this.friction || 0.5;
props.restitution ??= this.bounciness || 0.2;
if (!this.body) {
this.body = $.world.createBody({
position: scaleTo(this.x, this.y, this.tileSize),
type: this.collider
});
this.body.sprite = this;
} else this.body.m_gravityScale ||= 1;
let com = new pl.Vec2(this.body.getLocalCenter());
// mass is recalculated in createFixture
this.body.createFixture(props);
if (this.watch) this.mod[19] = true;
// reset the center of mass to the sprite's center
this.body.setMassData({
mass: this.body.getMass(),
center: com,
I: this.body.getInertia()
});
}
/**
* Adds a sensor to the sprite's physics body.
*
* Sensors can't displace or be displaced by colliders.
* Sensors don't have any mass or other physical properties.
* Sensors simply detect overlaps with other sensors.
*
* This function accepts parameters in a similar format to the Sprite
* constructor except the first two parameters are x and y offsets,
* the relative distance the new sensor should be from the center of
* the sprite.
*
* If a sensor is added to a sprite that has no collider (type "none")
* then internally it will be given a dynamic physics body that isn't
* affected by gravity so that the sensor can be added to it.
*
* @param {Number} offsetX - distance from the center of the sprite
* @param {Number} offsetY - distance from the center of the sprite
* @param {Number} w - width of the collider
* @param {Number} h - height of the collider
*/
addSensor(offsetX, offsetY, w, h) {
if (this._removed) {
console.error("Can't add sensors to a sprite that was removed.");
return;
}
let s = this._parseShape(...arguments);
if (!this.body) {
this.body = $.world.createBody({
position: scaleTo(this.x, this.y, this.tileSize),
type: 'dynamic',
gravityScale: 0
});
this.body.sprite = this;
this.mass = 0;
this._massUndef = true;
this.rotation = this._rotation;
this.vel = this._velocity;
}
this.body.createFixture({
shape: s,
isSensor: true
});
this._sortFixtures();
this._hasSensors = true;
}
_parseShape(offsetX, offsetY, w, h) {
let args = [...arguments];
let path, shape;
if (args.length == 0) {
offsetX = 0;
offsetY = 0;
w = this._w;
h = this._h;
} else if (args.length <= 2) {
offsetX = 0;
offsetY = 0;
w = args[0];
h = args[1];
this._vertexMode = true;
}
let dimensions;
// if (w is vertex array) or (side length and h is a
// collider type or the name of a regular polygon)
if (Array.isArray(w) || typeof h == 'string') {
if (!isNaN(w)) w = Number(w);
if (typeof w != 'number' && Array.isArray(w[0])) {
this._originMode ??= 'start';
}
if (typeof h == 'string') {
path = getRegularPolygon(w, h);
h = undefined;
} else {
path = w;
}
} else {
if (w !== undefined && h === undefined) {
shape = 'circle';
} else {
shape = 'box';
}
w ??= this.tileSize > 1 ? 1 : 50;
h ??= w;
// the actual dimensions of the collider for a box or circle are a
// little bit smaller so that they can slid past each other
// when in a tile grid
dimensions = scaleTo(w - 0.08, h - 0.08, this.tileSize);
}
let s;
if (shape == 'box') {
s = pl.Box(dimensions.x / 2, dimensions.y / 2, scaleTo(offsetX, offsetY, this.tileSize), 0);
} else if (shape == 'circle') {
s = pl.Circle(scaleTo(offsetX, offsetY, this.tileSize), dimensions.x / 2);
} else if (path) {
let vecs = [{ x: 0, y: 0 }];
let vert = { x: 0, y: 0 };
let min = { x: 0, y: 0 };
let max = { x: 0, y: 0 };
// if the path is an array of position arrays
let usesVertices = Array.isArray(path[0]);
function checkVert() {
if (vert.x < min.x) min.x = vert.x;
if (vert.y < min.y) min.y = vert.y;
if (vert.x > max.x) max.x = vert.x;
if (vert.y > max.y) max.y = vert.y;
}
let x, y;
if (usesVertices) {
if (this._vertexMode) {
x = path[0][0];
y = path[0][1];
// log(x, y);
if (!this.fixture || !this._relativeOrigin) {
this.x = x;
this.y = y;
} else {
x = this.x - this._relativeOrigin.x;
y = this.y - this._relativeOrigin.y;
vecs.pop();
}
}
for (let i = 0; i < path.length; i++) {
if (this._vertexMode) {
if (i == 0 && !this.fixture) continue;
// verts are relative to the first vert
vert.x = path[i][0] - x;
vert.y = path[i][1] - y;
} else {
vert.x += path[i][0];
vert.y += path[i][1];
}
vecs.push({ x: vert.x, y: vert.y });
checkVert();
}
} else {
let rep = 1;
if (path.length % 2) rep = path[path.length - 1];
let mod = rep > 0 ? 1 : -1;
rep = Math.abs(rep);
let ang = 0;
for (let i = 0; i < rep; i++) {
for (let j = 0; j < path.length - 1; j += 2) {
let len = path[j];
ang += path[j + 1];
vert.x += len * $.cos(ang);
vert.y += len * $.sin(ang);
vecs.push({ x: vert.x, y: vert.y });
checkVert();
}
ang *= mod;
}
}
let isConvex = false;
if (
isSlop(Math.abs(vecs[0].x) - Math.abs(vecs[vecs.length - 1].x)) &&
isSlop(Math.abs(vecs[0].y) - Math.abs(vecs[vecs.length - 1].y))
) {
if (this._shape != 'chain') shape = 'polygon';
else shape = 'chain';
this._originMode = 'center';
if (this._isConvexPoly(vecs.slice(0, -1))) isConvex = true;
} else {
shape = 'chain';
}
w = max.x - min.x;
h = max.y - min.y;
if (this._originMode == 'start') {
for (let i = 0; i < vecs.length; i++) {
vecs[i] = scaleTo(vecs[i].x, vecs[i].y, this.tileSize);
}
} else {
// the center relative to the first vertex
let centerX = 0;
let centerY = 0;
// use centroid of a triangle method to get center
// average of all vertices
let sumX = 0;
let sumY = 0;
let vl = vecs.length;
// last vertex is same as first
if (shape == 'polygon' || isConvex) vl--;
for (let i = 0; i < vl; i++) {
sumX += vecs[i].x;
sumY += vecs[i].y;
}
centerX = sumX / vl;
centerY = sumY / vl;
if (!this.fixture) {
this._relativeOrigin = { x: centerX, y: centerY };
}
if (this._vertexMode && usesVertices) {
if (!this.fixture) {
// repositions the sprite's x, y coordinates
// to be in the center of the shape
this.x += centerX;
this.y += centerY;
} else {
centerX = this._relativeOrigin.x;
centerY = this._relativeOrigin.y;
}
}
for (let i = 0; i < vecs.length; i++) {
let vec = vecs[i];
vecs[i] = scaleTo(vec.x + offsetX - centerX, vec.y + offsetY - centerY, this.tileSize);
}
}
if (!isConvex || vecs.length - 1 > pl.Settings.maxPolygonVertices || this._shape == 'chain') {
shape = 'chain';
}
if (shape == 'polygon') {
s = pl.Polygon(vecs);
} else if (shape == 'chain') {
s = pl.Chain(vecs, false);
}
}
this.shape ??= shape;
if (!this.fixtureList) {
this._w = w;
this._hw = w * 0.5;
if (this.__shape != 1) {
this._h = h;
this._hh = h * 0.5;
}
} else {
// top, bottom, left, right
this._extents ??= { t: this.hh, b: this.hh, l: this._hw, r: this._hw };
let ex = this._extents;
let l = offsetX - w * 0.5;
let r = offsetX + w * 0.5;
let t = offsetY - h * 0.5;
let b = offsetY + h * 0.5;
if (l < ex.l) ex.l = l;
if (r > ex.r) ex.r = r;
if (t < ex.t) ex.t = t;
if (b > ex.b) ex.b = b;
this._totalWidth = ex.r - ex.l;
this._totalHeight = ex.b - ex.t;
let abs = Math.abs;
this._largestExtent = Math.max(abs(ex.l), abs(ex.r), abs(ex.t), abs(ex.b));
}
return s;
}
/**
* Removes the physics body colliders from the sprite but not
* overlap sensors.
*/
removeColliders() {
if (!this.body) return;
this._removeContacts(0);
this._removeFixtures(0);
}
/**
* Removes overlap sensors from the sprite.
*/
removeSensors() {
if (!this.body) return;
this._removeContacts(1);
this._removeFixtures(1);
this._hasSensors = false;
}
/*
* removes sensors or colliders or both
* @param type can be undefined, 0, or 1
* undefined removes both
* 0 removes colliders
* 1 removes sensors
*/
_removeFixtures(type) {
let prevFxt;
for (let fxt = this.fixtureList; fxt; fxt = fxt.getNext()) {
if (type === undefined || fxt.m_isSensor == type) {
let _fxt = fxt.m_next;
fxt.destroyProxies($.world.m_broadPhase);
if (!prevFxt) {
this.body.m_fixtureList = _fxt;
} else {
prevFxt.m_next = _fxt;
}
} else {
prevFxt = fxt;
}
}
}
/*
* Removes contacts
* @param type can be undefined, 0, or 1
* undefined removes both
* 0 removes colliders
* 1 removes sensors
*/
_removeContacts(type) {
if (!this.body) return;
let ce = this.body.m_contactList;
while (ce) {
let con = ce.contact;
ce = ce.next;
if (type === undefined || con.m_fixtureA.m_isSensor == type) {
$.world.destroyContact(con);
}
}
}
_offsetCenterBy(x, y) {
if (!x && !y) return;
this._offset._x += x;
this._offset._y += y;
if (!this.body) return;
let off = scaleTo(x, y, this.tileSize);
this.__offsetCenterBy(off.x, off.y);
}
__offsetCenterBy(x, y) {
for (let fxt = this.body.m_fixtureList; fxt; fxt = fxt.m_next) {
let shape = fxt.m_shape;
if (shape.m_type != 'circle') {
let vertices = shape.m_vertices;
for (let v of vertices) {
v.x += x;
v.y += y;
}
} else {
shape.m_p.x += x;
shape.m_p.y += y;
}
}
}
/*
* Clones the collider's props to be transferred to a new collider.
*/
_cloneBodyProps() {
let body = {};
let props = [
'bounciness',
'density',
'drag',
'friction',
'heading',
'isSuperFast',
'rotation',
'rotationDrag',
'rotationLock',
'rotationSpeed',
'scale',
'vel',
'x',
'y'
];
// if mass or dimensions were defined by the user,
// then the mass setting should be copied to the new body
// else the new body's mass should be calculated based
// on its dimensions
if (!this._massUndef || !this._dimensionsUndef) {
props.push('mass');
}
for (let prop of props) {
if (typeof this[prop] == 'object') {
body[prop] = Object.assign({}, this[prop]);
} else {
body[prop] = this[prop];
}
}
return body;
}
/**
* Reference to the sprite's current animation.
* @type {Ani}
*/
get animation() {
return this._ani;
}
set animation(val) {
this.changeAni(val);
}
/**
* Reference to the sprite's current animation.
* @type {Ani}
*/
get ani() {
return this._ani;
}
set ani(val) {
this.changeAni(val);
}
/**
* Keys are the animation label, values are Ani objects
* @type {Anis}
*/
get anis() {
return this.animations;
}
/**
* Controls whether a sprite is updated before each physics update,
* when users let p5play automatically manage the frame cycle.
* @type {Boolean}
* @default true
*/
get autoUpdate() {
return this._autoUpdate;
}
set autoUpdate(val) {
this._autoUpdate = val;
}
/**
* Controls whether a sprite is drawn after each physics update,
* when users let p5play automatically manage the frame cycle.
* @type {Boolean}
* @default true
*/
get autoDraw() {
return this._autoDraw;
}
set autoDraw(val) {
this._autoDraw = val;
}
/**
* Controls the ability for a sprite to "sleep".
*
* "Sleeping" sprites are not included in the physics simulation, a
* sprite starts "sleeping" when it stops moving and doesn't collide
* with anything that it wasn't already touching.
* @type {Boolean}
* @default true
*/
get allowSleeping() {
return this.body?.isSleepingAllowed();
}
set allowSleeping(val) {
if (this.watch) this.mod[5] = true;
if (this.body) this.body.setSleepingAllowed(val);
}
/**
* The bounciness of the sprite's physics body.
* @type {Number}
* @default 0.2
*/
get bounciness() {
if (!this.fixture) return;
return this.fixture.getRestitution();
}
set bounciness(val) {
if (this.watch) this.mod[7] = true;
for (let fxt = this.fixtureList; fxt; fxt = fxt.getNext()) {
fxt.setRestitution(val);
}
}
/**
* The sprite's collider type. Default is "dynamic".
*
* The collider type can be one of the following strings:
* "dynamic", "static", "kinematic", "none".
*
* The letters "d", "s", "k", "n" can be used as shorthand.
*
* When a sprite with a collider type of "d", "s", or "k" is
* changed to "none", or vice versa, the sprite will
* maintain its current position, velocity, rotation, and
* rotation speed.
*
* Sprites can't have their collider type
* set to "none" if they have a polygon or chain collider,
* multiple colliders, or multiple sensors.
*
* To achieve the same effect as setting collider type
* to "none", use `.overlaps(allSprites)` to have your
* sprite overlap with all sprites.
*
* @type {String}
* @default 'dynamic'
*/
get collider() {
return this._collider;
}
set collider(val) {
if (val == this._collider) return;
val = val.toLowerCase();
let c = val[0];
if (c == 'd') val = 'dynamic';
if (c == 's') val = 'static';
if (c == 'k') val = 'kinematic';
if (c == 'n') val = 'none';
if (val == this._collider) return;
if (val == 'none' && (this._shape == 'chain' || this._shape == 'polygon')) {
console.error(
'Cannot set the collider type of a polygon or chain collider to "none". To achieve the same effect, use .overlaps(allSprites) to have your sprite overlap with the allSprites group.'
);
return;
}
if (this._removed) {
throw new Error('Cannot change the collider type of a sprite that was removed.');
}
let oldCollider = this.__collider;
this._collider = val;
this.__collider = ['d', 's', 'k', 'n'].indexOf(c);
if (this.watch) this.mod[8] = true;
if (oldCollider === undefined) return;
if (this.__collider != 3) {
if (this.body) this.body.setType(val);
if (oldCollider == 3) {
this.addCollider();
this.x = this._position.x;
this.y = this._position.y;
this.vel.x = this._velocity.x;
this.vel.y = this._velocity.y;
this.rotation = this._rotation;
this.rotationSpeed = this._rotationSpeed;
}
} else {
this.removeColliders();
if (this.fixture?.m_isSensor) this.body.m_gravityScale = 0;
else {
this._syncWithPhysicsBody();
$.world.destroyBody(this.body);
this.body = null;
}
}
}
_syncWithPhysicsBody() {
this._position.x = this.x;
this._position.y = this.y;
this._velocity.x = this.vel.x;
this._velocity.y = this.vel.y;
this._rotation = this.rotation;
this._rotationSpeed = this.rotationSpeed;
}
_parseColor(val) {
// false if color was copied with Object.assign
if (val instanceof p5.Color) {
return val;
} else if (typeof val != 'object') {
if (val.length == 1) return $.colorPal(val);
else return $.color(val);
}
return $.color(...val.levels);
}
/**
* The sprite's current color. By default sprites get a random color.
* @type {Color}
* @default random color
*/
get color() {
return this._color;
}
set color(val) {
if (this.watch) this.mod[9] = true;
this._color = this._parseColor(val);
}
/**
* Alias for color. colour is the British English spelling.
* @type {Color}
* @default random color
*/
get colour() {
return this._color;
}
set colour(val) {
this.color = val;
}
/**
* Alias for sprite.fillColor
* @type {Color}
* @default random color
*/
get fill() {
return this._color;
}
set fill(val) {
this.color = val;
}
/**
* Overrides sprite's stroke color. By default the stroke of a sprite
* is determined by its collider type, which can also be overridden
* by the sketch's stroke color.
* @type {Color}
* @default undefined
*/
get stroke() {
return this._stroke;
}
set stroke(val) {
if (this.watch) this.mod[29] = true;
this._stroke = this._parseColor(val);
}
/**
* The sprite's stroke weight, the thickness of its outline.
* @type {Number}
* @default undefined
*/
get strokeWeight() {
return this._strokeWeight;
}
set strokeWeight(val) {
if (this.watch) this.mod[30] = true;
this._strokeWeight = val;
}
/**
* The sprite's text fill color. Black by default.
* @type {Color}
* @default black (#000000)
*/
get textColor() {
return this._textFill;
}
set textColor(val) {
if (this.watch) this.mod[32] = true;
this._textFill = this._parseColor(val);
}
get textColour() {
return this._textFill;
}
set textColour(val) {
this.textColor = val;
}
/**
* The sprite's text fill color. Black by default.
* @type {Color}
* @default black (#000000)
*/
get textFill() {
return this._textFill;
}
set textFill(val) {
this.textColor = val;
}
/**
* The sprite's text size, the sketch's current textSize by default.
* @type {Number}
*/
get textSize() {
return this._textSize;
}
set textSize(val) {
if (this.watch) this.mod[33] = true;
this._textSize = val;
}
/**
* The sprite's text stroke color.
* No stroke by default, does not inherit from the sketch's stroke color.
* @type {Color}
* @default undefined
*/
get textStroke() {
return this._textStroke;
}
set textStroke(val) {
if (this.watch) this.mod[34] = true;
this._textStroke = this._parseColor(val);
}
/**
* The sprite's text stroke weight, the thickness of its outline.
* No stroke by default, does not inherit from the sketch's stroke weight.
* @type {Number}
* @default undefined
*/
get textStrokeWeight() {
return this._textStrokeWeight;
}
set textStrokeWeight(val) {
if (this.watch) this.mod[35] = true;
this._textStrokeWeight = val;
}
/**
* The tile string represents the sprite in a tile map.
* @type {String}
*/
get tile() {
return this._tile;
}
set tile(val) {
if (this.watch) this.mod[36] = true;
this._tile = val;
}
/**
* DEPRECATED: Will be removed in version 4.
*
* The tile size is used to change the size of one unit of
* measurement for the sprite.
*
* For example, if the tile size is 16, then a sprite with
* x=1 and y=1 will be drawn at position (16, 16) on the canvas.
* @deprecated
* @type {Number}
* @default 1
*/
get tileSize() {
return this._tileSize;
}
set tileSize(val) {
if (this.watch) this.mod[37] = true;
this._tileSize = val;
}
/**
* A bearing indicates the direction that needs to be followed to
* reach a destination. Setting a sprite's bearing doesn't do
* anything by itself. You can apply a force at the sprite's
* bearing angle using the `applyForce` function.
* @type {Number}
* @example
* sprite.bearing = angle;
* sprite.applyForce(amount);
*/
get bearing() {
return this._bearing;
}
set bearing(val) {
if (this.watch) this.mod[6] = true;
this._bearing = val;
}
/**
* If true, an outline of the sprite's collider will be drawn.
* @type {Boolean}
* @default false
*/
get debug() {
return this._debug;
}
set debug(val) {
if (this.watch) this.mod[10] = true;
this._debug = val;
}
/**
* The density of the sprite's physics body.
* @type {Number}
* @default 5
*/
get density() {
if (!this.fixture) return;
return this.fixture.getDensity();
}
set density(val) {
if (this.watch) this.mod[11] = true;
for (let fxt = this.fixtureList; fxt; fxt = fxt.getNext()) {
fxt.setDensity(val);
}
}
_getDirectionAngle(name) {
name = name.toLowerCase().replaceAll(/[ _-]/g, '');
let dirs = {
up: -90,
down: 90,
left: 180,
right: 0,
upright: -45,
rightup: -45,
upleft: -135,
leftup: -135,
downright: 45,
rightdown: 45,
downleft: 135,
leftdown: 135,
forward: this.rotation,
backward: this.rotation + 180
};
let val = dirs[name];
if ($._angleMode == 'radians') {
val = $.radians(val);
}
return val;
}
/**
* The angle of the sprite's movement.
* @type {Number}
* @default 0 ("right")
*/
get direction() {
if (this.vel.x !== 0 || this.vel.y !== 0) {
return $.atan2(this.vel.y, this.vel.x);
}
if (this._isTurtleSprite) return this.rotation;
return this._direction;
}
set direction(val) {
if (this.watch) this.mod[12] = true;
if (typeof val == 'string') {
this._heading = val;
val = this._getDirectionAngle(val);
}
this._direction = val;
if (this._isTurtleSprite) this.rotation = val;
let speed = this.speed;
this.vel.x = $.cos(val) * speed;
this.vel.y = $.sin(val) * speed;
}
/**
* The amount of resistance a sprite has to being moved.
* @type {Number}
* @default 0
*/
get drag() {
return this.body?.getLinearDamping();
}
set drag(val) {
if (this.watch) this.mod[13] = true;
if (this.body) this.body.setLinearDamping(val);
}
/**
* Displays the sprite.
*
* This function is called automatically at the end of each
* sketch `draw` function call but it can also be run
* by users to customize the order sprites are drawn in relation
* to other stuff drawn to the canvas. Also see the sprite.layer
* property.
*
* A sprite's draw function can be overridden with a
* custom draw function, inside this function (0, 0) is the center of
* the sprite.
*
* Using this function actually calls the sprite's internal `_display`
* function, which sets up the canvas for drawing the sprite before
* calling the sprite's `_draw` function. See the example below for how to
* run the sprite's default `_draw` function inside your custom `draw` function.
*
* @type {Function}
* @example
* let defaultDraw = sprite._draw;
*
* sprite.draw = function() {
* // add custom code here
* defaultDraw();
* }
*/
get draw() {
return this._display;
}
set draw(val) {
this._userDefinedDraw = true;
this._draw = val;
}
/**
* True if the sprite's physics body is dynamic.
* @type {Boolean}
* @default true
*/
get dynamic() {
return this.body?.isDynamic();
}
set dynamic(val) {
if (val) this.collider = 'dynamic';
else this.collider = 'kinematic';
}
/**
* Returns the first node in a linked list of the planck physics
* body's fixtures.
*/
get fixture() {
return this.fixtureList;
}
/**
* Returns the first node in a linked list of the planck physics
* body's fixtures.
*/
get fixtureList() {
if (!this.body) return null;
return this.body.m_fixtureList;
}
/**
* The amount the sprite's physics body resists moving
* when rubbing against another physics body.
* @type {Number}
* @default 0.5
*/
get friction() {
if (!this.fixture) return;
return this.fixture.getFriction();
}
set friction(val) {
if (this.watch) this.mod[14] = true;
for (let fxt = this.fixtureList; fxt; fxt = fxt.getNext()) {
fxt.setFriction(val);
}
}
/**
* The sprite's heading. This is a string that can be set to
* "up", "down", "left", "right", "upRight", "upLeft", "downRight"
*
* It ignores cardinal direction word order, capitalization, spaces,
* underscores, and dashes.
* @type {String}
* @default undefined
*/
get heading() {
return this._heading;
}
set heading(val) {
this.direction = val;
}
/**
* Alias for `sprite.image`.
* @type {Image}
*/
get img() {
return this._img || this._ani?.frameImage;
}
set img(val) {
this.image = val;
}
/**
* The sprite's image or current frame of animation.
*
* When `sprite.image` is set, two properties are added:
*
* `sprite.image.offset` determines the x and y position the image
* should be drawn at relative to the sprite's center.
*