Back to Sketch Loop
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.

src/sketch.ts
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().');
  }
}