r/javascript • u/Maximum_Beat2034 • 14h ago
AskJS [AskJS] BeeLadybug — Open Source Canvas 2D Debug Overlay in Vanilla JS (Zero Dependencies)
I built BeeLadybug, a zero-dependency visual debugging overlay for my Vanilla JS Canvas engine. It renders AABB hitboxes in world space (green for clear, red for collisions) and a screen-space HUD with FPS, active entities, cycle time, and freeze-frame / slow-motion controls (F2/F3/F4). Here is the clean source code:
export const BEE_LADYBUG_DEFAULTS = Object.freeze({
toggleKey: 'F2',
slowKey: 'F3',
freezeKey: 'F4',
slowScale: 0.25,
colorActive: '#3dff6a',
colorColliding: '#ff3b3b',
colorInactive: '#8a8a8a',
overlayX: 12,
overlayY: 12
});
export class BeeLadybug {
constructor(engine, options = {}) {
this.engine = engine;
this.configure(options);
this.enabled = false;
this.#entities = [];
this.#colliding = new Set();
this.#buttons = [];
this.#onKeyDown = (event) => this.#handleKey(event);
this.#bound = false;
}
#entities;
#colliding;
#buttons;
#onKeyDown;
#bound;
configure(options = {}) {
const cfg = { ...BEE_LADYBUG_DEFAULTS, ...options };
this.toggleKey = cfg.toggleKey;
this.slowKey = cfg.slowKey;
this.freezeKey = cfg.freezeKey;
this.slowScale = cfg.slowScale;
this.colorActive = cfg.colorActive;
this.colorColliding = cfg.colorColliding;
this.colorInactive = cfg.colorInactive;
this.overlayX = cfg.overlayX;
this.overlayY = cfg.overlayY;
return this;
}
attach() {
if (this.#bound) return this;
window.addEventListener('keydown', this.#onKeyDown);
this.#bound = true;
return this;
}
detach() {
if (!this.#bound) return this;
window.removeEventListener('keydown', this.#onKeyDown);
this.#bound = false;
return this;
}
destroy() {
this.detach();
this.enabled = false;
this.#entities.length = 0;
this.#colliding.clear();
this.#buttons.length = 0;
}
show() {
this.enabled = true;
return this;
}
hide() {
this.enabled = false;
return this;
}
toggle() {
this.enabled = !this.enabled;
return this;
}
applySlowMo() {
const time = this.engine.time;
if (!time) return this;
if (time.paused) time.resume();
const next = time.timeScale === this.slowScale ? 1 : this.slowScale;
this.engine.setTimeScale(next);
return this;
}
toggleFreeze() {
const time = this.engine.time;
if (!time) return this;
time.togglePause();
return this;
}
restoreRealtime() {
const time = this.engine.time;
if (!time) return this;
if (time.paused) time.resume();
this.engine.setTimeScale(1);
return this;
}
#handleKey(event) {
if (event.repeat) return;
if (event.code === this.toggleKey) {
event.preventDefault();
this.toggle();
return;
}
if (!this.enabled) return;
if (event.code === this.slowKey) {
event.preventDefault();
this.applySlowMo();
return;
}
if (event.code === this.freezeKey) {
event.preventDefault();
this.toggleFreeze();
}
}
#collectEntities() {
const list = this.#entities;
list.length = 0;
const seen = new Set();
const visit = (entity) => {
if (!entity || entity.destroyed || seen.has(entity)) return;
seen.add(entity);
list.push(entity);
const kids = entity.children;
if (!kids) return;
for (let i = 0; i < kids.length; i++) {
visit(kids[i]);
}
};
const engine = this.engine;
const roots = engine.entities;
if (roots) {
for (let i = 0; i < roots.length; i++) visit(roots[i]);
}
const scene = engine.scenes && engine.scenes.currentScene
? engine.scenes.currentScene
: engine.currentScene;
if (scene && scene.entities) {
for (let i = 0; i < scene.entities.length; i++) visit(scene.entities[i]);
}
const groups = engine.collisions && engine.collisions.groups;
if (groups && typeof groups.values === 'function') {
for (const bucket of groups.values()) {
if (!bucket) continue;
for (let i = 0; i < bucket.length; i++) visit(bucket[i]);
}
}
return list;
}
#aabbOf(entity) {
const engine = this.engine;
if (engine && typeof engine.getEntityDrawBounds === 'function') {
return engine.getEntityDrawBounds(entity);
}
if (!entity) return null;
return {
x: typeof entity.worldX === 'number' ? entity.worldX : entity.x,
y: typeof entity.worldY === 'number' ? entity.worldY : entity.y,
width: entity.width ?? 0,
height: entity.height ?? 0
};
}
#refreshCollisions(list) {
const colliding = this.#colliding;
colliding.clear();
const boxes = [];
for (let i = 0; i < list.length; i++) {
const bounds = this.#aabbOf(list[i]);
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
boxes.push(null);
continue;
}
boxes.push(bounds);
}
for (let i = 0; i < list.length; i++) {
const a = boxes[i];
if (!a) continue;
for (let j = i + 1; j < list.length; j++) {
const b = boxes[j];
if (!b) continue;
if (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
) {
colliding.add(list[i]);
colliding.add(list[j]);
}
}
}
}
drawWorld(ctx) {
if (!this.enabled) return;
const list = this.#collectEntities();
this.#refreshCollisions(list);
ctx.save();
ctx.lineWidth = 1.5;
for (let i = 0; i < list.length; i++) {
const entity = list[i];
const box = this.#aabbOf(entity);
if (!box || box.width <= 0 || box.height <= 0) continue;
const colliding = this.#colliding.has(entity);
const active = entity.active !== false;
const color = colliding
? this.colorColliding
: (active ? this.colorActive : this.colorInactive);
ctx.strokeStyle = color;
ctx.fillStyle = colliding ? 'rgba(255, 59, 59, 0.16)' : 'rgba(61, 255, 106, 0.08)';
ctx.fillRect(box.x, box.y, box.width, box.height);
ctx.strokeRect(box.x + 0.5, box.y + 0.5, box.width, box.height);
}
ctx.restore();
}
drawOverlay(ctx) {
if (!this.enabled) return;
const engine = this.engine;
const time = engine.time;
const list = this.#entities;
let activeCount = 0;
for (let i = 0; i < list.length; i++) {
if (list[i].active !== false) activeCount += 1;
}
const fps = time ? time.fps : 0;
const cycleMs = time ? time.unscaledDt * 1000 : 0;
const scale = time ? time.timeScale : 1;
const frozen = time ? time.paused : false;
const x = this.overlayX;
const y = this.overlayY;
const width = 268;
const height = 168;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(10, 12, 18, 0.82)';
ctx.strokeStyle = '#ff4d4d';
ctx.lineWidth = 2;
if (typeof ctx.roundRect === 'function') {
ctx.beginPath();
ctx.roundRect(x, y, width, height, 8);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, width, height);
ctx.strokeRect(x, y, width, height);
}
ctx.fillStyle = '#ff4d4d';
ctx.beginPath();
ctx.arc(x + 22, y + 22, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1a1a';
ctx.beginPath();
ctx.arc(x + 19, y + 20, 2, 0, Math.PI * 2);
ctx.arc(x + 25, y + 20, 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffe08a';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('BeeLadybug', x + 38, y + 14);
ctx.fillStyle = '#d8d8d8';
ctx.font = '12px monospace';
const lines = [
`FPS ${fps.toFixed(0)}`,
`ENTITA ${activeCount} attive / ${list.length} in memoria`,
`CICLO ${cycleMs.toFixed(2)} ms`,
`SCALE ${scale.toFixed(2)}x SIM ${frozen ? 'FREEZE' : 'RUN'}`
];
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], x + 14, y + 42 + i * 16);
}
this.#buttons = [
{ id: 'slow', label: 'F3 SLOW', x: x + 12, y: y + 114, w: 78, h: 22 },
{ id: 'freeze', label: 'F4 STOP', x: x + 96, y: y + 114, w: 78, h: 22 },
{ id: 'live', label: '1x LIVE', x: x + 180, y: y + 114, w: 74, h: 22 }
];
for (let i = 0; i < this.#buttons.length; i++) {
const btn = this.#buttons[i];
const hot = (btn.id === 'slow' && scale !== 1 && !frozen)
|| (btn.id === 'freeze' && frozen)
|| (btn.id === 'live' && scale === 1 && !frozen);
ctx.fillStyle = hot ? '#ff4d4d' : '#2a2f3a';
ctx.fillRect(btn.x, btn.y, btn.w, btn.h);
ctx.strokeStyle = '#ffe08a';
ctx.strokeRect(btn.x + 0.5, btn.y + 0.5, btn.w, btn.h);
ctx.fillStyle = '#fff6d8';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(btn.label, btn.x + btn.w / 2, btn.y + btn.h / 2);
}
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#9aa0aa';
ctx.font = '10px monospace';
ctx.fillText('F2 mostra/nasconde verde=ok rosso=collide', x + 12, y + 146);
ctx.restore();
}
poll() {
if (!this.enabled) return;
const input = this.engine.input;
if (!input || !input.mouse || !input.mouse.wasPressed) return;
const mx = input.mouse.x;
const my = input.mouse.y;
for (let i = 0; i < this.#buttons.length; i++) {
const btn = this.#buttons[i];
if (mx < btn.x || mx > btn.x + btn.w || my < btn.y || my > btn.y + btn.h) continue;
if (btn.id === 'slow') this.applySlowMo();
else if (btn.id === 'freeze') this.toggleFreeze();
else if (btn.id === 'live') this.restoreRealtime();
break;
}
}
}
1
Upvotes
•
u/Designer_Shelter7345 8h ago
Neat little debug layer, the ladybug motif on the HUD is a fun touch