UNPKG

p5play

Version:

A JavaScript game engine that uses p5.js for graphics and Box2D for physics.

1,949 lines (1,770 loc) 279 kB
/** * 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. *