SOURCE / PINNED RELEASE
Made of little things.
Sketch Loop
- Release
- 14966bba4a2a…
- Author-recorded commit
- e68361295d81…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-929bc2a88cb
Archive hash verified: 5e0385e3c08f944f…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { Interpreter, parseSketch } from './interpreter.js';
export const examples = [
{ name: 'BusyMozaic', credit: 'BusyMozaic · Kurt Moerman (@KurtMoerman4)', code: `// BusyMozaic — Kurt Moerman (@KurtMoerman4)
s=6; t=0;
draw=_=>{
t++ || createCanvas(2*(W=225),2*(H=150))+background(0);
for(x=0;x<2*W;x+=s){
y=(x^t)%(2*H); y=s*~~(y/s);
u=1.7*(x-W); v=1.7*(y-H)+150;
d=~~abs(u**3/4E4-u+3*v-v**2/1E2);
p=(d/s)%(s&t);
fill(d%256,(d%128)*2,(d%32)*8);
rect(x-p,y-p,2*p);
}
}` },
{ name: 'Orbit study', credit: 'Orbit study · built-in example', code: `function setup() {
createCanvas(450, 450);
colorMode(HSB, 360, 100, 100, 100);
noStroke();
}
function draw() {
background(230, 40, 7, 12);
translate(width/2, height/2);
for (let i=0; i<36; i++) {
let a = frameCount/70 + i*TWO_PI/36;
let r = 110 + 55*sin(frameCount/90 + i/5);
fill((i*10 + frameCount/3)%360, 65, 100);
circle(cos(a)*r, sin(a)*r, 5 + 4*sin(a));
}
}` },
{ name: 'Ribbon field', credit: 'Ribbon field · built-in example', code: `function setup() {
createCanvas(540, 360);
noFill();
strokeWeight(2);
}
function draw() {
background(12, 20, 25);
for (let j=0; j<24; j++) {
stroke(65+j*7, 220-j*4, 175+j*3);
beginShape();
for (let x=0; x<=width; x+=6) {
let wave = sin(x/85 + frameCount/50 + j/9);
vertex(x, 30+j*13 + wave*26);
}
endShape();
}
}` },
];
const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
type AnyFn = (...args: any[]) => any;
export class Sketch {
readonly canvas: HTMLCanvasElement;
readonly source: string;
private ctx: CanvasRenderingContext2D;
private vm: Interpreter;
frame = 0;
fps = 60;
looping = true;
private filled = true;
private stroked = true;
private degrees = false;
private rectangleMode = 'corner';
private ellipseModeValue = 'center';
private mode = 'rgb';
private ranges = [255,255,255,255];
private seed = 123456789;
private noiseSeedValue = 42;
private shapeStarted = false;
private states: any[] = [];
private elapsed = 0;
constructor(raw: string) {
const parsed = parseSketch(raw); this.source = parsed.source;
this.canvas = document.createElement('canvas'); this.canvas.width = 450; this.canvas.height = 300;
const context = this.canvas.getContext('2d', { willReadFrequently: true });
if (!context) throw new Error('Canvas drawing is unavailable in this browser.');
this.ctx = context; this.defaults();
this.vm = new Interpreter(this.api());
this.vm.run(parsed.ast);
this.vm.invoke('setup');
if (!this.vm.has('draw') && !this.vm.has('setup')) throw new Error('Add a draw function or a setup function to your sketch.');
}
private defaults(): void { this.ctx.fillStyle = '#fff'; this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 1; this.ctx.lineCap = 'round'; }
private angle(n: number): number { return this.degrees ? n * Math.PI / 180 : n; }
private paint(): void { if (this.filled) this.ctx.fill(); if (this.stroked) this.ctx.stroke(); }
private color(args: any[]): string {
if (Array.isArray(args[0])) args = args[0];
if (typeof args[0] === 'string') return args[0];
if (args.length <= 2) {
const gray = clamp(Number(args[0]) / (this.mode === 'rgb' ? this.ranges[0] : this.ranges[2]) * 255, 0, 255);
return `rgba(${gray},${gray},${gray},${args[1] === undefined ? 1 : clamp(args[1]/this.ranges[3],0,1)})`;
}
const alpha = args[3] === undefined ? 1 : clamp(args[3] / this.ranges[3], 0, 1);
if (this.mode === 'rgb') return `rgba(${args.slice(0,3).map((n: number,i: number)=>clamp(n/this.ranges[i]*255,0,255)).join(',')},${alpha})`;
const h = ((args[0]/this.ranges[0]*360)%360+360)%360;
const s = clamp(args[1]/this.ranges[1],0,1); const v = clamp(args[2]/this.ranges[2],0,1);
const l = this.mode === 'hsb' ? v*(1-s/2) : v;
const sat = this.mode === 'hsb' ? (l === 0 || l === 1 ? 0 : (v-l)/Math.min(l,1-l)) : s;
return `hsla(${h},${sat*100}%,${l*100}%,${alpha})`;
}
private random(): number { this.seed = (Math.imul(1664525, this.seed)+1013904223) >>> 0; return this.seed / 4294967296; }
private noise(x: number, y=0, z=0): number {
const smooth = (t: number) => t*t*(3-2*t);
const hash = (a: number,b: number,c: number) => { let h = Math.imul(a,374761393)^Math.imul(b,668265263)^Math.imul(c,2147483647)^this.noiseSeedValue; h = Math.imul(h^(h>>>13),1274126177); return ((h^(h>>>16))>>>0)/4294967296; };
const ix=Math.floor(x),iy=Math.floor(y),iz=Math.floor(z),tx=smooth(x-ix),ty=smooth(y-iy),tz=smooth(z-iz);
const mix=(a:number,b:number,t:number)=>a+(b-a)*t;
const layer=(k:number)=>mix(mix(hash(ix,iy,k),hash(ix+1,iy,k),tx),mix(hash(ix,iy+1,k),hash(ix+1,iy+1,k),tx),ty);
return mix(layer(iz),layer(iz+1),tz);
}
private api(): Record<string, any> {
const ctx = this.ctx;
const globals: Record<string, any> = {
width: this.canvas.width, height: this.canvas.height, windowWidth: 450, windowHeight: 300,
frameCount: 0, deltaTime: 1000/60, mouseX: 0, mouseY: 0, mouseIsPressed: false,
PI: Math.PI, TWO_PI: Math.PI*2, TAU: Math.PI*2, HALF_PI: Math.PI/2, QUARTER_PI: Math.PI/4,
RGB: 'rgb', HSB: 'hsb', HSL: 'hsl', CENTER: 'center', CORNER: 'corner', CORNERS: 'corners', RADIUS: 'radius', CLOSE: 'close', DEGREES: 'degrees', RADIANS: 'radians', ROUND:'round', SQUARE:'butt', PROJECT:'square',
createCanvas: (w: number,h: number,renderer?: string) => {
if (renderer) throw new Error('Only the default 2D canvas is supported.');
if (![w,h].every(n=>Number.isFinite(n)&&n>=1&&n<=1024)) throw new Error('Canvas dimensions must be between 1 and 1,024 pixels.');
this.canvas.width = Math.floor(w); this.canvas.height = Math.floor(h); this.defaults();
this.vm?.expose('width',this.canvas.width); this.vm?.expose('height',this.canvas.height); return 0;
},
background: (...args: any[]) => { ctx.save(); ctx.resetTransform(); ctx.fillStyle = this.color(args); ctx.fillRect(0,0,this.canvas.width,this.canvas.height); ctx.restore(); return 0; },
clear: () => { ctx.save(); ctx.resetTransform(); ctx.clearRect(0,0,this.canvas.width,this.canvas.height); ctx.restore(); },
fill: (...args: any[]) => { ctx.fillStyle = this.color(args); this.filled = true; },
stroke: (...args: any[]) => { ctx.strokeStyle = this.color(args); this.stroked = true; },
noFill: () => { this.filled = false; }, noStroke: () => { this.stroked = false; },
strokeWeight: (n: number) => { ctx.lineWidth = clamp(n,0.01,1024); },
strokeCap: (cap: CanvasLineCap) => { if (['round','butt','square'].includes(cap)) ctx.lineCap = cap; },
rectMode: (mode: string) => { this.rectangleMode = mode; }, ellipseMode: (mode: string) => { this.ellipseModeValue = mode; },
rect: (x:number,y:number,w:number,h=w) => {
if (![x,y,w,h].every(Number.isFinite)) return;
if (this.rectangleMode === 'center') { x-=w/2; y-=h/2; }
else if (this.rectangleMode === 'radius') { x-=w; y-=h; w*=2; h*=2; }
else if (this.rectangleMode === 'corners') { w-=x; h-=y; }
ctx.beginPath(); ctx.rect(x,y,w,h); this.paint();
},
ellipse: (x:number,y:number,w:number,h=w) => {
if (![x,y,w,h].every(Number.isFinite)) return;
if (this.ellipseModeValue === 'corner') { x+=w/2; y+=h/2; }
if (this.ellipseModeValue === 'radius') { w*=2; h*=2; }
ctx.beginPath(); ctx.ellipse(x,y,Math.abs(w/2),Math.abs(h/2),0,0,Math.PI*2); this.paint();
},
line: (a:number,b:number,c:number,d:number) => { if (![a,b,c,d].every(Number.isFinite)) return; ctx.beginPath(); ctx.moveTo(a,b); ctx.lineTo(c,d); if(this.stroked)ctx.stroke(); },
point: (x:number,y:number) => { if (!this.stroked || ![x,y].every(Number.isFinite))return; ctx.save(); ctx.fillStyle=ctx.strokeStyle; ctx.beginPath();ctx.arc(x,y,ctx.lineWidth/2,0,Math.PI*2);ctx.fill();ctx.restore(); },
triangle: (...v:number[])=> {ctx.beginPath();ctx.moveTo(v[0],v[1]);ctx.lineTo(v[2],v[3]);ctx.lineTo(v[4],v[5]);ctx.closePath();this.paint();},
beginShape: ()=> {this.shapeStarted=false;ctx.beginPath();},
vertex: (x:number,y:number)=> {if(this.shapeStarted)ctx.lineTo(x,y);else ctx.moveTo(x,y);this.shapeStarted=true;},
bezierVertex: (...v:number[])=>ctx.bezierCurveTo(v[0],v[1],v[2],v[3],v[4],v[5]),
endShape: (mode?:string)=> {if(mode==='close')ctx.closePath();this.paint();},
translate: (x:number,y:number)=>ctx.translate(x,y), rotate: (n:number)=>ctx.rotate(this.angle(n)), scale: (x:number,y=x)=>ctx.scale(x,y), resetMatrix: ()=>ctx.resetTransform(),
push: ()=> {if(this.states.length>=64)throw new Error('Too many push() calls. Pair each push() with pop().');ctx.save();this.states.push([this.filled,this.stroked,this.rectangleMode,this.ellipseModeValue,this.mode,[...this.ranges]]);},
pop: ()=> {const s=this.states.pop();if(!s)throw new Error('pop() needs a matching push().');ctx.restore();[this.filled,this.stroked,this.rectangleMode,this.ellipseModeValue,this.mode,this.ranges]=s;},
colorMode: (mode:string,...ranges:number[])=> {if(!['rgb','hsb','hsl'].includes(mode))throw new Error('Use RGB, HSB or HSL.');this.mode=mode;this.ranges=ranges.length===1?[ranges[0],ranges[0],ranges[0],ranges[0]]:ranges.length>=3?[ranges[0],ranges[1],ranges[2],ranges[3]??255]:mode==='rgb'?[255,255,255,255]:[360,100,100,1];},
color: (...args:any[])=>this.color(args),
angleMode: (mode:string)=> {this.degrees=mode==='degrees';},
frameRate: (n?:number)=> {if(n!==undefined){if(!Number.isFinite(n)||n<1||n>60)throw new Error('Sketch frame rate must be 1–60 fps.');this.fps=n;}return this.fps;},
noLoop: ()=> {this.looping=false;}, loop: ()=> {this.looping=true;}, pixelDensity: ()=>1,
millis: ()=>this.elapsed*1000,
randomSeed: (n:number)=> {this.seed=n>>>0;}, random: (a?:any,b?:number)=>{const r=this.random();return Array.isArray(a)?a[Math.floor(r*a.length)]:a===undefined?r:b===undefined?r*a:a+r*(b-a);},
noiseSeed: (n:number)=> {this.noiseSeedValue=n>>>0;},noise:(x:number,y?:number,z?:number)=>this.noise(x,y,z),
map:(n:number,a:number,b:number,c:number,d:number,bounded=false)=>{const v=c+(n-a)/(b-a)*(d-c);return bounded?clamp(v,Math.min(c,d),Math.max(c,d)):v;},
constrain:clamp,lerp:(a:number,b:number,t:number)=>a+(b-a)*t,dist:(a:number,b:number,c:number,d:number)=>Math.hypot(c-a,d-b),mag:(a:number,b:number)=>Math.hypot(a,b),norm:(n:number,a:number,b:number)=>(n-a)/(b-a),sq:(n:number)=>n*n,
radians:(n:number)=>n*Math.PI/180,degrees:(n:number)=>n*180/Math.PI,
int:(n:number)=>Math.trunc(n),float:(n:any)=>parseFloat(n),str:(n:any)=>String(n),
textSize:(n:number)=> {ctx.font=`${clamp(n,1,512)}px sans-serif`;},text:(s:any,x:number,y:number)=>{if(this.filled)ctx.fillText(String(s).slice(0,2000),x,y);if(this.stroked)ctx.strokeText(String(s).slice(0,2000),x,y);},
};
for (const name of ['abs','ceil','floor','round','sqrt','pow','exp','log','min','max','sign','hypot','trunc']) globals[name]=(Math as unknown as Record<string,AnyFn>)[name];
for (const name of ['sin','cos','tan']) globals[name]=(n:number)=>(Math as unknown as Record<string,AnyFn>)[name](this.angle(n));
for (const name of ['asin','acos','atan']) globals[name]=(n:number)=>{const r=(Math as unknown as Record<string,AnyFn>)[name](n);return this.degrees?r*180/Math.PI:r;};
globals.atan2=(y:number,x:number)=>{const r=Math.atan2(y,x);return this.degrees?r*180/Math.PI:r;};
globals.circle=globals.ellipse; globals.square=globals.rect;
const math: Record<string,any> = Object.create(null);
for(const k of Object.getOwnPropertyNames(Math)) math[k]=(Math as any)[k];
math.random=()=>this.random(); globals.Math=math;
return globals;
}
step(): void {
if (this.frame>0 && !this.looping) return;
this.frame++; this.elapsed+=1/this.fps;
this.vm.expose('frameCount',this.frame);this.vm.expose('deltaTime',1000/this.fps);
this.ctx.resetTransform(); this.vm.invoke('draw');
if (this.states.length) throw new Error('Pair every push() with pop() before the end of draw().');
}
}