mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 08:00:53 +00:00
feat(time scale): adjust time scale logic, add rawDelta fix(migrateEntityId): update $ceMap on entity id change chore(tests): update tests chore(README): update readme to reflect these new changes
910 lines
27 KiB
JavaScript
Executable File
910 lines
27 KiB
JavaScript
Executable File
// src/misc.ts
|
|
function pulse(time, freq = 1, min = 0, max = 1) {
|
|
const halfRange = (max - min) / 2;
|
|
return min + halfRange * (1 + Math.sin(2 * Math.PI * freq * time));
|
|
}
|
|
|
|
// src/ids.ts
|
|
var HEX = [];
|
|
for (let i = 0; i < 256; i++) {
|
|
HEX[i] = (i + 256).toString(16).substring(1);
|
|
}
|
|
function pad(str, size) {
|
|
const s = "000000" + str;
|
|
return s.substring(s.length - size);
|
|
}
|
|
var SHARD_COUNT = 32;
|
|
function getCreateId(opts) {
|
|
const len = opts.len || 16;
|
|
let str = "";
|
|
let num = 0;
|
|
const discreteValues = 1679616;
|
|
let current = opts.init + Math.ceil(discreteValues / 2);
|
|
function counter() {
|
|
current = current <= discreteValues ? current : 0;
|
|
current++;
|
|
return (current - 1).toString(16);
|
|
}
|
|
return () => {
|
|
if (!str || num === 256) {
|
|
str = "";
|
|
num = (1 + len) / 2 | 0;
|
|
while (num--) str += HEX[256 * Math.random() | 0];
|
|
str = str.substring(num = 0, len);
|
|
}
|
|
const date = Date.now().toString(36);
|
|
const paddedCounter = pad(counter(), 6);
|
|
const hex = HEX[num++];
|
|
const shardKey = parseInt(hex, 16) % SHARD_COUNT;
|
|
return `ngn${date}${paddedCounter}${hex}${str}${shardKey}`;
|
|
};
|
|
}
|
|
|
|
// src/ngn.ts
|
|
var createId = getCreateId({ init: 0, len: 4 });
|
|
var $eciMap = Symbol();
|
|
var $ceMap = Symbol();
|
|
var $eMap = Symbol();
|
|
var $queryResults = Symbol();
|
|
var $dirtyQueries = Symbol();
|
|
var $queryDependencies = Symbol();
|
|
var $systems = Symbol();
|
|
var $running = Symbol();
|
|
var $onEntityCreated = Symbol();
|
|
var $mainLoop = Symbol();
|
|
var createWorld = () => {
|
|
const state = {
|
|
[$eciMap]: {},
|
|
[$ceMap]: {},
|
|
[$eMap]: {},
|
|
[$dirtyQueries]: /* @__PURE__ */ new Set(),
|
|
[$queryDependencies]: /* @__PURE__ */ new Map(),
|
|
[$queryResults]: {},
|
|
[$systems]: [],
|
|
[$mainLoop]: null,
|
|
time: {
|
|
elapsed: 0,
|
|
delta: 0,
|
|
loopDelta: 0,
|
|
lastLoopDelta: 0,
|
|
scale: 1,
|
|
fps: 0
|
|
},
|
|
[$running]: false,
|
|
[$onEntityCreated]: []
|
|
};
|
|
const defineMain2 = (callback) => {
|
|
state[$mainLoop] = callback;
|
|
};
|
|
const start2 = () => {
|
|
let then = 0;
|
|
let accumulator = 0;
|
|
const boundLoop = handler.bind(start2);
|
|
let loopHandler = -1;
|
|
const { time } = state;
|
|
time.delta = 0;
|
|
time.elapsed = 0;
|
|
time.fps = 0;
|
|
state[$running] = true;
|
|
let raf = null;
|
|
let craf = null;
|
|
if (typeof window !== "undefined") {
|
|
let now = performance.now();
|
|
raf = (cb) => {
|
|
return requestAnimationFrame((timestamp) => {
|
|
now = timestamp;
|
|
cb(now);
|
|
});
|
|
};
|
|
craf = cancelAnimationFrame;
|
|
} else {
|
|
let now = 0;
|
|
raf = (cb) => {
|
|
return setTimeout(() => {
|
|
now += 16.67;
|
|
cb(now);
|
|
}, 16.67);
|
|
};
|
|
craf = (id) => {
|
|
clearTimeout(id);
|
|
};
|
|
}
|
|
let xfps = 1;
|
|
const xtimes = [];
|
|
function handler(now) {
|
|
if (!state[$running]) return craf(loopHandler);
|
|
while (xtimes.length > 0 && xtimes[0] <= now - 1e3) {
|
|
xtimes.shift();
|
|
}
|
|
xtimes.push(now);
|
|
xfps = xtimes.length;
|
|
time.fps = xfps;
|
|
time.delta = now - then;
|
|
then = now;
|
|
accumulator += time.delta * time.scale;
|
|
const stepThreshold = 1e3 / (time.fps || 60);
|
|
while (accumulator >= stepThreshold) {
|
|
time.loopDelta = now - time.lastLoopDelta;
|
|
time.lastLoopDelta = now;
|
|
state[$mainLoop](state);
|
|
accumulator -= stepThreshold;
|
|
}
|
|
time.elapsed += time.delta * 1e-3;
|
|
loopHandler = raf(boundLoop);
|
|
}
|
|
loopHandler = raf(boundLoop);
|
|
return () => state[$running] = false;
|
|
};
|
|
const stop = () => {
|
|
state[$running] = false;
|
|
};
|
|
function step2() {
|
|
for (const system of state[$systems]) {
|
|
if (system(state) === null) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
function addSystem2(...systems) {
|
|
for (const system of systems) {
|
|
if (typeof system === "function") {
|
|
state[$systems].push(system);
|
|
} else if (system.update && typeof system.update === "function") {
|
|
state[$systems].push(system.update);
|
|
} else {
|
|
throw new Error(`Not a valid system: ${JSON.stringify(system)}`);
|
|
}
|
|
}
|
|
}
|
|
function removeSystem(...systems) {
|
|
for (const system of systems) {
|
|
if (typeof system === "function") {
|
|
state[$systems] = state[$systems].filter((s) => s !== system);
|
|
} else if (system.update && typeof system.update === "function") {
|
|
state[$systems] = state[$systems].filter((s) => s !== system.update);
|
|
} else {
|
|
throw new TypeError("Parameter must be a function or an object with an update function.");
|
|
}
|
|
}
|
|
}
|
|
const getQuery = (queryConfig, queryName) => {
|
|
if (!state[$dirtyQueries].has(queryName) && state[$queryResults][queryName]) {
|
|
return state[$queryResults][queryName].results;
|
|
}
|
|
const { and = [], or = [], not = [], tag = [] } = queryConfig;
|
|
const entities = Object.values(state[$eMap]).filter((entity) => {
|
|
return (!not.length || !not.some((component) => entity.hasComponent(component))) && (!and.length || and.every((component) => entity.hasComponent(component))) && (!or.length || or.some((component) => entity.hasComponent(component))) && (!tag.length || tag.some((t) => entity.tag === t));
|
|
});
|
|
state[$queryResults][queryName] = {
|
|
results: entities.map((entity) => {
|
|
const result = { entity };
|
|
entity.components.forEach((component) => {
|
|
result[component.__ngn__.name] = component;
|
|
});
|
|
return result;
|
|
})
|
|
};
|
|
state[$dirtyQueries].delete(queryName);
|
|
return state[$queryResults][queryName].results;
|
|
};
|
|
const markQueryDirty = (queryName) => {
|
|
state[$dirtyQueries].add(queryName);
|
|
};
|
|
const query = ({ and = [], or = [], not = [], tag = [] }) => {
|
|
const validQuery = (c) => Object.prototype.hasOwnProperty.call(c, "name");
|
|
if (![...and, ...or, ...not].every(validQuery)) throw new Error("Invalid query");
|
|
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
|
|
[...and, ...or, ...not].forEach((c) => {
|
|
const dependencies = state[$queryDependencies].get(c.name) || /* @__PURE__ */ new Set();
|
|
dependencies.add(queryName);
|
|
state[$queryDependencies].set(c.name, dependencies);
|
|
});
|
|
tag.forEach((t) => {
|
|
const tagKey = `tag:${t}`;
|
|
const dependencies = state[$queryDependencies].get(tagKey) || /* @__PURE__ */ new Set();
|
|
dependencies.add(queryName);
|
|
state[$queryDependencies].set(tagKey, dependencies);
|
|
});
|
|
return (queryImpl) => queryImpl(getQuery({ and, or, not, tag }, queryName));
|
|
};
|
|
function destroyEntity(e) {
|
|
const exists = state[$eMap][e.id];
|
|
if (!exists) return false;
|
|
const componentsToRemove = Object.keys(state[$eciMap][e.id]);
|
|
componentsToRemove.forEach((componentName) => {
|
|
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
|
});
|
|
delete state[$eciMap][e.id];
|
|
delete state[$eMap][e.id];
|
|
componentsToRemove.forEach((componentName) => {
|
|
const affectedQueries = state[$queryDependencies].get(componentName);
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
function onEntityCreated(fn) {
|
|
if (typeof fn !== "function") return;
|
|
state[$onEntityCreated].push(fn);
|
|
return () => {
|
|
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
|
};
|
|
}
|
|
function createComponent(entity, component, defaults = {}) {
|
|
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0) return entity;
|
|
const affectedQueries = state[$queryDependencies].get(component.name);
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
const componentInstance = component();
|
|
if (componentInstance.onAttach && typeof componentInstance.onAttach === "function") {
|
|
componentInstance.onAttach(entity);
|
|
}
|
|
entity.components.push(
|
|
Object.assign(
|
|
{},
|
|
{
|
|
...componentInstance,
|
|
...defaults,
|
|
__ngn__: {
|
|
parent: entity.id,
|
|
name: component.name
|
|
}
|
|
}
|
|
)
|
|
);
|
|
state[$eciMap][entity.id] = state[$eciMap][entity.id] || {};
|
|
state[$eciMap][entity.id][component.name] = entity.components.length - 1;
|
|
state[$ceMap][component.name] = state[$ceMap][component.name] || [];
|
|
state[$ceMap][component.name].push(entity.id);
|
|
return entity;
|
|
}
|
|
function createEntity(spec = {}) {
|
|
const id = spec.id ?? createId();
|
|
const components = [];
|
|
const tagKey = (t) => `tag:${t}`;
|
|
function updateTagQueries(tagKey2) {
|
|
const affectedQueries = state[$queryDependencies].get(tagKey2);
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
}
|
|
function addTag(t) {
|
|
const previousTagKey = tagKey(this.tag);
|
|
this.tag = t;
|
|
updateTagQueries(tagKey(t));
|
|
updateTagQueries(previousTagKey);
|
|
return this;
|
|
}
|
|
function removeTag() {
|
|
const previousTagKey = tagKey(this.tag);
|
|
this.tag = "";
|
|
updateTagQueries(previousTagKey);
|
|
return this;
|
|
}
|
|
function getTag() {
|
|
return this.tag;
|
|
}
|
|
function addComponent(c, defaults = {}) {
|
|
return createComponent(this, c, defaults);
|
|
}
|
|
function hasComponent(component) {
|
|
return state[$eciMap]?.[id]?.[component.name] !== void 0;
|
|
}
|
|
function getComponent(arg) {
|
|
const index = state[$eciMap][id][arg.name];
|
|
return components[index];
|
|
}
|
|
function removeComponent(component) {
|
|
const name = typeof component === "string" ? component : component.name;
|
|
const componentInstance = getComponent(typeof component === "string" ? { name } : component);
|
|
if (componentInstance && componentInstance.onDetach && typeof componentInstance.onDetach === "function") {
|
|
componentInstance.onDetach(this);
|
|
}
|
|
const affectedQueries = state[$queryDependencies].get(name);
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
state[$eciMap][id][name] = void 0;
|
|
state[$ceMap][name] = state[$ceMap][name].filter((e) => e !== id);
|
|
const index = state[$eciMap][id][name];
|
|
components.splice(index, 1);
|
|
Object.keys(state[$eciMap][id]).forEach((componentName) => {
|
|
if (state[$eciMap][id][componentName] > components.findIndex((c) => c.name === componentName)) {
|
|
state[$eciMap][id][componentName]--;
|
|
}
|
|
});
|
|
return this;
|
|
}
|
|
function destroy() {
|
|
return destroyEntity(this);
|
|
}
|
|
const entity = Object.assign({}, spec, {
|
|
id,
|
|
components,
|
|
addTag,
|
|
removeTag,
|
|
getTag,
|
|
addComponent,
|
|
hasComponent,
|
|
getComponent,
|
|
removeComponent,
|
|
destroy
|
|
});
|
|
if (spec.id !== void 0 && state[$eMap][spec.id]) {
|
|
migrateEntityId(spec.id, createId());
|
|
}
|
|
state[$eMap][id] = entity;
|
|
state[$eciMap][id] = {};
|
|
state[$onEntityCreated].forEach((fn) => {
|
|
fn(entity);
|
|
});
|
|
return entity;
|
|
}
|
|
function migrateEntityId(oldId, newId) {
|
|
const entity = state[$eMap][oldId];
|
|
if (!entity) return;
|
|
entity.id = newId;
|
|
state[$eMap][newId] = entity;
|
|
delete state[$eMap][oldId];
|
|
state[$eciMap][newId] = state[$eciMap][oldId];
|
|
delete state[$eciMap][oldId];
|
|
}
|
|
function getEntity(id) {
|
|
return state[$eMap][id];
|
|
}
|
|
return {
|
|
state,
|
|
query,
|
|
createEntity,
|
|
getEntity,
|
|
onEntityCreated,
|
|
addSystem: addSystem2,
|
|
removeSystem,
|
|
start: start2,
|
|
stop,
|
|
step: step2,
|
|
defineMain: defineMain2
|
|
};
|
|
};
|
|
|
|
// src/packages/2d/canvas.ts
|
|
var createCanvas = (options) => {
|
|
const canvas2 = document.createElement("canvas");
|
|
const { target, fullscreen } = options;
|
|
const { body } = window.document;
|
|
if (target && fullscreen) {
|
|
options.target = null;
|
|
} else if (!target && !fullscreen) {
|
|
options.fullscreen = true;
|
|
}
|
|
if (fullscreen) {
|
|
Object.assign(canvas2.style, {
|
|
position: "absolute",
|
|
top: "0",
|
|
left: "0"
|
|
});
|
|
canvas2.width = window.innerWidth;
|
|
canvas2.height = window.innerHeight;
|
|
body.appendChild(canvas2);
|
|
Object.assign(body.style, {
|
|
margin: "0",
|
|
padding: "0",
|
|
width: "100%",
|
|
height: "100%",
|
|
overflow: "hidden"
|
|
});
|
|
}
|
|
if (target) {
|
|
target.appendChild(canvas2);
|
|
target.style.overflow = "hidden";
|
|
canvas2.width = canvas2.offsetWidth;
|
|
canvas2.height = canvas2.offsetHeight;
|
|
}
|
|
canvas2.width = canvas2.offsetWidth;
|
|
canvas2.height = canvas2.offsetHeight;
|
|
canvas2.style.width = "100%";
|
|
canvas2.style.height = "100%";
|
|
const existingMeta = window.document.querySelector(`meta[name="viewport"]`);
|
|
if (existingMeta) {
|
|
Object.assign(existingMeta, {
|
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
|
});
|
|
} else {
|
|
const meta = Object.assign(window.document.createElement("meta"), {
|
|
name: "viewport",
|
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
|
});
|
|
window.document.head.appendChild(meta);
|
|
}
|
|
return canvas2;
|
|
};
|
|
|
|
// src/packages/2d/draw.ts
|
|
var fastRound = (num) => ~~(0.5 + num);
|
|
var fastRoundVector2 = (v) => ({
|
|
x: fastRound(v.x),
|
|
y: fastRound(v.y)
|
|
});
|
|
var createDraw = (context) => {
|
|
const text = (v, text2, color = "black", size = 16) => {
|
|
v = fastRoundVector2(v);
|
|
context.save();
|
|
context.fillStyle = color;
|
|
context.font = `${size}px sans-serif`;
|
|
context.fillText(text2, v.x, v.y);
|
|
context.restore();
|
|
};
|
|
const line = (from, to, color = "black", lineWidth = 1) => {
|
|
from = fastRoundVector2(from);
|
|
to = fastRoundVector2(to);
|
|
context.save();
|
|
context.beginPath();
|
|
context.moveTo(from.x, from.y);
|
|
context.lineTo(to.x, to.y);
|
|
context.strokeStyle = color;
|
|
context.lineWidth = lineWidth;
|
|
context.stroke();
|
|
context.closePath();
|
|
context.restore();
|
|
};
|
|
const rectangle = (pos, dimensions, color = "black", lineWidth = 1) => {
|
|
pos = fastRoundVector2(pos);
|
|
dimensions = fastRoundVector2(dimensions);
|
|
context.save();
|
|
context.beginPath();
|
|
context.rect(pos.x, pos.y, dimensions.x, dimensions.y);
|
|
context.lineWidth = lineWidth;
|
|
context.strokeStyle = color;
|
|
context.stroke();
|
|
context.closePath();
|
|
context.restore();
|
|
};
|
|
const circle = (pos, radius = 25, color = "black", lineWidth = 1) => {
|
|
pos = fastRoundVector2(pos);
|
|
context.save();
|
|
context.beginPath();
|
|
context.strokeStyle = color;
|
|
context.lineWidth = lineWidth;
|
|
context.arc(pos.x, pos.y, radius, 0, Math.PI * 2, true);
|
|
context.stroke();
|
|
context.closePath();
|
|
context.restore();
|
|
};
|
|
return {
|
|
text,
|
|
line,
|
|
rectangle,
|
|
circle
|
|
};
|
|
};
|
|
|
|
// src/packages/2d/create2d.ts
|
|
var create2D = (options) => {
|
|
const { width = 800, height = 600, fullscreen = false, target = null } = options.canvas;
|
|
const canvas2 = createCanvas({ width, height, fullscreen, target });
|
|
const context = canvas2.getContext("2d");
|
|
const draw2 = createDraw(context);
|
|
const onWindowResize = () => {
|
|
if (fullscreen) {
|
|
canvas2.style.width = "100%";
|
|
canvas2.style.height = "100%";
|
|
canvas2.width = window.innerWidth;
|
|
canvas2.height = window.innerHeight;
|
|
return;
|
|
}
|
|
canvas2.width = canvas2.offsetWidth;
|
|
canvas2.height = canvas2.offsetHeight;
|
|
};
|
|
window.addEventListener("resize", onWindowResize);
|
|
const destroy = () => {
|
|
window.removeEventListener("resize", onWindowResize);
|
|
canvas2.parentElement.removeChild(canvas2);
|
|
};
|
|
return { canvas: canvas2, context, draw: draw2, destroy };
|
|
};
|
|
|
|
// src/packages/emitter/index.ts
|
|
var getDefaultParticleEmitterOptions = (opts) => ({
|
|
...opts,
|
|
x: opts.x ?? 0,
|
|
y: opts.y ?? 0,
|
|
maxParticles: opts.maxParticles ?? 100,
|
|
rate: opts.rate ?? 1,
|
|
lifetime: opts.lifetime ?? 1e3,
|
|
lifetimeVariation: opts.lifetimeVariation ?? 0,
|
|
size: opts.size ?? 5,
|
|
sizeVariation: opts.sizeVariation ?? 0,
|
|
colorStart: opts.colorStart ?? "#000000",
|
|
colorEnd: opts.colorEnd ?? "#000000",
|
|
colorEasing: opts.colorEasing ?? "linear" /* LINEAR */,
|
|
angle: opts.angle ?? 0,
|
|
spread: opts.spread ?? 0,
|
|
gravity: opts.gravity ?? { x: 0, y: 0 },
|
|
speed: opts.speed ?? 0.1,
|
|
speedVariation: opts.speedVariation ?? 0,
|
|
canvas: opts.canvas,
|
|
burst: opts.burst ?? false
|
|
});
|
|
var interpolateColor = (colorStart, colorEnd, factor, easing) => {
|
|
switch (easing) {
|
|
case "easeIn" /* EASE_IN */:
|
|
factor = Math.pow(factor, 2);
|
|
break;
|
|
case "easeOut" /* EASE_OUT */:
|
|
factor = 1 - Math.pow(1 - factor, 2);
|
|
break;
|
|
case "easeInOut" /* EASE_IN_OUT */:
|
|
if (factor < 0.5) {
|
|
factor = 2 * Math.pow(factor, 2);
|
|
} else {
|
|
factor = 1 - 2 * Math.pow(1 - factor, 2);
|
|
}
|
|
break;
|
|
case "linear" /* LINEAR */:
|
|
default:
|
|
break;
|
|
}
|
|
const color1 = parseInt(colorStart.slice(1), 16);
|
|
const color2 = parseInt(colorEnd.slice(1), 16);
|
|
const r1 = color1 >> 16 & 255;
|
|
const g1 = color1 >> 8 & 255;
|
|
const b1 = color1 & 255;
|
|
const r2 = color2 >> 16 & 255;
|
|
const g2 = color2 >> 8 & 255;
|
|
const b2 = color2 & 255;
|
|
const r = Math.round(r1 + factor * (r2 - r1));
|
|
const g = Math.round(g1 + factor * (g2 - g1));
|
|
const b = Math.round(b1 + factor * (b2 - b1));
|
|
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
|
};
|
|
var hexToRgb = (hex) => {
|
|
const color = parseInt(hex.slice(1), 16);
|
|
const r = color >> 16 & 255;
|
|
const g = color >> 8 & 255;
|
|
const b = color & 255;
|
|
return `${r}, ${g}, ${b}`;
|
|
};
|
|
var createParticleEmitter = (opts) => {
|
|
opts = getDefaultParticleEmitterOptions(opts);
|
|
const particles = [];
|
|
let timeSinceLastEmission = 0;
|
|
const emissionInterval = 1 / opts.rate;
|
|
const lifetimeVariation = opts.lifetimeVariation ?? 0;
|
|
const context = opts.canvas.getContext("2d");
|
|
const angleInRadians = opts.angle * (Math.PI / 180);
|
|
let dead = false;
|
|
let paused = false;
|
|
const update = (state) => {
|
|
if (dead) return;
|
|
context.globalCompositeOperation = opts.blendMode ?? "source-over";
|
|
const { loopDelta } = state.time;
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
const particle = particles[i];
|
|
const lifeFactor = particle.lifetime / opts.lifetime;
|
|
let opacity = 1;
|
|
particle.color = interpolateColor(particle.colorStart, particle.colorEnd, 1 - lifeFactor, opts.colorEasing);
|
|
if (opts.fadeOutEasing) {
|
|
switch (opts.fadeOutEasing) {
|
|
case "easeIn" /* EASE_IN */:
|
|
opacity = Math.pow(lifeFactor, 2);
|
|
break;
|
|
case "easeOut" /* EASE_OUT */:
|
|
opacity = 1 - Math.pow(1 - lifeFactor, 2);
|
|
break;
|
|
case "easeInOut" /* EASE_IN_OUT */:
|
|
if (lifeFactor < 0.5) {
|
|
opacity = 2 * Math.pow(lifeFactor, 2);
|
|
} else {
|
|
opacity = 1 - 2 * Math.pow(1 - lifeFactor, 2);
|
|
}
|
|
break;
|
|
case "linear" /* LINEAR */:
|
|
default:
|
|
opacity = lifeFactor;
|
|
break;
|
|
}
|
|
}
|
|
if (!paused) {
|
|
particle.x += particle.speedX * loopDelta;
|
|
particle.y += particle.speedY * loopDelta;
|
|
particle.speedX += opts.gravity.x * loopDelta / 1e3;
|
|
particle.speedY += opts.gravity.y * loopDelta / 1e3;
|
|
particle.lifetime -= state.time.loopDelta;
|
|
if (opts.onUpdate) {
|
|
opts.onUpdate(particle, state);
|
|
}
|
|
if (particle.onUpdate) {
|
|
particle.onUpdate(particle, state);
|
|
}
|
|
}
|
|
if (particle.lifetime <= 0) {
|
|
if (opts.onRemove) {
|
|
opts.onRemove(particle, state);
|
|
}
|
|
if (particle.onRemove) {
|
|
particle.onRemove(particle, state);
|
|
}
|
|
particles.splice(i, 1);
|
|
} else {
|
|
context.fillStyle = `rgba(${hexToRgb(particle.color)}, ${opacity})`;
|
|
context.beginPath();
|
|
context.rect(particle.x * particle.scaleX, particle.y * particle.scaleY, particle.size, particle.size);
|
|
context.closePath();
|
|
context.fill();
|
|
}
|
|
}
|
|
const emitParticle = () => {
|
|
const lifetimeVariationAmount = lifetimeVariation ? opts.lifetime * lifetimeVariation * Math.random() : 0;
|
|
const particleLifetime = opts.lifetime + lifetimeVariationAmount * (Math.random() < 0.5 ? -1 : 1);
|
|
const colorStart = Array.isArray(opts.colorStart) ? opts.colorStart[Math.floor(Math.random() * opts.colorStart.length)] : opts.colorStart;
|
|
const colorEnd = Array.isArray(opts.colorEnd) ? opts.colorEnd[Math.floor(Math.random() * opts.colorEnd.length)] : opts.colorEnd;
|
|
const particle = spawnParticle({
|
|
x: opts.x,
|
|
y: opts.y,
|
|
colorStart,
|
|
colorEnd,
|
|
color: colorStart,
|
|
lifetime: particleLifetime,
|
|
size: Math.max(0, opts.size + (Math.random() - 0.5) * opts.sizeVariation),
|
|
speedX: opts.speed * (Math.sin(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
|
speedY: -opts.speed * (Math.cos(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
});
|
|
if (opts.onInit) {
|
|
opts.onInit(particle, state);
|
|
}
|
|
if (particle.onInit) {
|
|
particle.onInit(particle, state);
|
|
}
|
|
};
|
|
if (!paused) {
|
|
if (opts.burst && timeSinceLastEmission === 0) {
|
|
for (let i = 0; i < opts.maxParticles; i++) {
|
|
emitParticle();
|
|
}
|
|
timeSinceLastEmission = -1;
|
|
} else if (!opts.burst) {
|
|
timeSinceLastEmission += loopDelta;
|
|
while (timeSinceLastEmission >= emissionInterval && particles.length < opts.maxParticles) {
|
|
emitParticle();
|
|
timeSinceLastEmission -= emissionInterval;
|
|
}
|
|
}
|
|
}
|
|
if (opts.burst && particles.length === 0) {
|
|
destroy();
|
|
}
|
|
};
|
|
const destroy = () => {
|
|
dead = true;
|
|
particles.length = 0;
|
|
return;
|
|
};
|
|
const spawnParticle = (p) => {
|
|
particles.push(p);
|
|
return p;
|
|
};
|
|
const pause = () => {
|
|
paused = true;
|
|
};
|
|
const resume = () => {
|
|
paused = false;
|
|
};
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") {
|
|
resume();
|
|
} else {
|
|
pause();
|
|
}
|
|
});
|
|
return {
|
|
particles,
|
|
update,
|
|
destroy,
|
|
pause,
|
|
resume,
|
|
set x(value) {
|
|
opts.x = value;
|
|
},
|
|
get x() {
|
|
return opts.x;
|
|
},
|
|
set y(value) {
|
|
opts.y = value;
|
|
},
|
|
get y() {
|
|
return opts.y;
|
|
}
|
|
};
|
|
};
|
|
var createParticleSystem = (opts) => {
|
|
let _x = opts.x;
|
|
let _y = opts.y;
|
|
const emitters = [];
|
|
const startImmediately = opts.start ?? true;
|
|
const update = (state) => {
|
|
emitters.forEach((emitter2) => {
|
|
emitter2.update(state);
|
|
});
|
|
};
|
|
const destroy = () => {
|
|
emitters.forEach((emitter2) => {
|
|
emitter2.destroy();
|
|
});
|
|
};
|
|
const createEmitter = (opts2) => {
|
|
const emitter2 = createParticleEmitter({
|
|
...opts2
|
|
/* x: _x, */
|
|
/* y: _y, */
|
|
});
|
|
emitters.push(emitter2);
|
|
if (!startImmediately) {
|
|
emitter2.pause();
|
|
}
|
|
return emitter2;
|
|
};
|
|
const pause = () => {
|
|
emitters.forEach((emitter2) => {
|
|
emitter2.pause();
|
|
});
|
|
};
|
|
const start2 = () => {
|
|
emitters.forEach((emitter2) => {
|
|
emitter2.resume();
|
|
});
|
|
};
|
|
return {
|
|
update,
|
|
destroy,
|
|
createEmitter,
|
|
pause,
|
|
start: start2,
|
|
set x(value) {
|
|
_x = value;
|
|
},
|
|
get x() {
|
|
return _x;
|
|
},
|
|
set y(value) {
|
|
_y = value;
|
|
},
|
|
get y() {
|
|
return _y;
|
|
},
|
|
get numParticles() {
|
|
return emitters.reduce((acc, emitter2) => acc + emitter2.particles.length, 0);
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/demo.ts
|
|
var { canvas, draw } = create2D({ canvas: { fullscreen: true } });
|
|
var particleSystem = createParticleSystem({
|
|
x: canvas.width / 2,
|
|
y: canvas.height / 2,
|
|
canvas
|
|
});
|
|
var emitter = particleSystem.createEmitter({
|
|
x: canvas.width / 2,
|
|
y: canvas.height / 2,
|
|
maxParticles: 120,
|
|
rate: 0.1,
|
|
lifetime: 1e3,
|
|
lifetimeVariation: 0.2,
|
|
size: 20,
|
|
sizeVariation: 10,
|
|
colorStart: ["#FF0000", "#ff5100"],
|
|
colorEnd: "#222222",
|
|
colorEasing: "easeIn" /* EASE_IN */,
|
|
fadeOutEasing: "easeOut" /* EASE_OUT */,
|
|
speed: 0.1,
|
|
speedVariation: 1,
|
|
angle: 0,
|
|
spread: 0.75,
|
|
gravity: { x: 0, y: 0 },
|
|
canvas,
|
|
burst: false,
|
|
onInit: (particle, state) => {
|
|
particle.x += Math.random() < 0.5 ? -6 : 6;
|
|
particle.y += Math.random() < 0.5 ? -6 : 6;
|
|
if (!(Math.random() < 0.02)) {
|
|
return;
|
|
}
|
|
particle.size = 15;
|
|
particle.speedY = -0.3;
|
|
particle.lifetime = 1e3;
|
|
particle.colorEnd = "#ff0000";
|
|
particle.onRemove = () => {
|
|
particleSystem.createEmitter({
|
|
x: particle.x,
|
|
y: particle.y,
|
|
maxParticles: 4,
|
|
lifetimeVariation: 0.5,
|
|
size: 3,
|
|
sizeVariation: 2,
|
|
colorStart: ["#FF0000", "#ff5100"],
|
|
colorEnd: "#222222",
|
|
colorEasing: "easeIn" /* EASE_IN */,
|
|
fadeOutEasing: "easeOut" /* EASE_OUT */,
|
|
speed: 0.02,
|
|
speedVariation: 1,
|
|
spread: 6,
|
|
angle: 180,
|
|
canvas,
|
|
burst: true
|
|
});
|
|
};
|
|
particle.onUpdate = () => {
|
|
particle.size = Math.max(0, particle.size + 0.25);
|
|
};
|
|
},
|
|
onUpdate: (particle, state) => {
|
|
particle.size = Math.max(0, particle.size - 0.35);
|
|
},
|
|
onRemove: (particle, state) => {
|
|
}
|
|
});
|
|
var { addSystem, start, step, defineMain } = createWorld();
|
|
var clearCanvasSystem = () => {
|
|
const context = canvas.getContext("2d");
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
context.fillStyle = "#111";
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
};
|
|
var fpsDrawSystem = (state) => {
|
|
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
|
|
};
|
|
var particleCountSystem = (state) => {
|
|
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}. Emitter count: ${emitter.particles.length}`, "white");
|
|
};
|
|
var particlePositionSystem = (state) => {
|
|
const { time } = state;
|
|
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
|
|
emitter.x = xPos;
|
|
};
|
|
addSystem(
|
|
clearCanvasSystem,
|
|
fpsDrawSystem,
|
|
particleCountSystem,
|
|
particlePositionSystem,
|
|
particleSystem
|
|
);
|
|
defineMain(() => {
|
|
step();
|
|
});
|
|
start();
|
|
var container = document.createElement("div");
|
|
container.style.position = "fixed";
|
|
container.style.bottom = "0";
|
|
container.style.left = "0";
|
|
container.style.padding = "10px";
|
|
container.style.display = "flex";
|
|
container.style.justifyContent = "space-between";
|
|
container.style.alignItems = "center";
|
|
document.body.appendChild(container);
|
|
var pauseButton = document.createElement("button");
|
|
pauseButton.innerText = "Pause";
|
|
pauseButton.onclick = () => {
|
|
particleSystem.pause();
|
|
};
|
|
container.appendChild(pauseButton);
|
|
var startButton = document.createElement("button");
|
|
startButton.innerText = "Start";
|
|
startButton.style.marginLeft = "5px";
|
|
startButton.onclick = () => {
|
|
particleSystem.start();
|
|
};
|
|
container.appendChild(startButton);
|
|
var destroyButton = document.createElement("button");
|
|
destroyButton.innerText = "Destroy";
|
|
destroyButton.style.marginLeft = "5px";
|
|
destroyButton.onclick = () => {
|
|
particleSystem.destroy();
|
|
};
|
|
container.appendChild(destroyButton);
|
|
//# sourceMappingURL=demo.js.map
|