memoscan
← all memos

Memo 0x867b0526…474e5d on Ethereum

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>SATARI</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <!-- SATARI ENGINE — zero-dependency p5 shim --> <script> /* ================================================================ MICRO-P5: minimal p5.js-compatible engine for SATARI Provides: canvas, draw loop, graphics buffers, HSB colors, shapes, blend modes, seeded RNG, noise, WebGL shader support ================================================================ */ // ─── CONSTANTS ─── var PI = Math.PI, TWO_PI = PI * 2, HALF_PI = PI / 2, QUARTER_PI = PI / 4 var TAU = TWO_PI var CLOSE = 'close' var CENTER = 1, CORNER = 0 var HSB = 'hsb', RGB = 'rgb' var WEBGL = 'webgl' var ROUND = 'round', SQUARE = 'butt', PROJECT = 'square' var BLEND = 'source-over', ADD = 'lighter', MULTIPLY = 'multiply' var SCREEN = 'screen', DIFFERENCE = 'difference', EXCLUSION = 'exclusion' var OVERLAY = 'overlay', REPLACE = 'copy' // ─── MATH GLOBALS ─── var { floor, ceil, round, abs, min, max, sin, cos, tan, atan2, sqrt, pow, log, exp, sign } = Math function lerp(a, b, t) { return a + (b - a) * t } function map(v, a1, b1, a2, b2) { return a2 + (v - a1) / (b1 - a1) * (b2 - a2) } function constrain(v, lo, hi) { return max(lo, min(hi, v)) } // ─── SEEDED RNG ─── var _rngState = 12345 function _rngNext() { let t = (_rngState += 0x6D2B79F5) t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } function randomSeed(s) { _rngState = s >>> 0 } function random(a, b) { const r = _rngNext() if (a === undefined) return r if (b === undefined) { if (Array.isArray(a)) return a[floor(r * a.length)] return r * a } return a + r * (b - a) } function randomGaussian(m, sd) { const u1 = _rngNext(), u2 = _rngNext() return (m || 0) + (sd || 1) * sqrt(-2 * log(u1 || 1e-10)) * cos(TWO_PI * u2) } // ─── PERLIN NOISE ─── var _noiseState = 0 var _noisePerm = new Uint8Array(512) function noiseSeed(s) { _noiseState = s const prev = _rngState; _rngState = s >>> 0 for (let i = 0; i < 256; i++) _noisePerm[i] = i for (let i = 255; i > 0; i--) { const j = floor(_rngNext() * (i + 1)) const tmp = _noisePerm[i]; _noisePerm[i] = _noisePerm[j]; _noisePerm[j] = tmp } for (let i = 0; i < 256; i++) _noisePerm[i + 256] = _noisePerm[i] _rngState = prev } function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10) } function _ngrad(hash, x) { return (hash & 1) ? -x : x } function noise(x, y, z) { x = x || 0; y = y || 0; z = z || 0 const X = floor(x) & 255, Y = floor(y) & 255, Z = floor(z) & 255 x -= floor(x); y -= floor(y); z -= floor(z) const u = _fade(x), v = _fade(y), w = _fade(z) const A = _noisePerm[X] + Y, B = _noisePerm[X + 1] + Y const AA = _noisePerm[A] + Z, AB = _noisePerm[A + 1] + Z const BA = _noisePerm[B] + Z, BB = _noisePerm[B + 1] + Z return (lerp( lerp(lerp(_ngrad(_noisePerm[AA], x), _ngrad(_noisePerm[BA], x - 1), u), lerp(_ngrad(_noisePerm[AB], x), _ngrad(_noisePerm[BB], x - 1), u), v), lerp(lerp(_ngrad(_noisePerm[AA + 1], x), _ngrad(_noisePerm[BA + 1], x - 1), u), lerp(_ngrad(_noisePerm[AB + 1], x), _ngrad(_noisePerm[BB + 1], x - 1), u), v), w ) + 1) / 2 // map to 0-1 } noiseSeed(0) // init permutation table // ─── COLOR SYSTEM ─── function _hsbToCSS(h, s, b, a) { h = ((h % 360) + 360) % 360 s = constrain(s, 0, 100) / 100 b = constrain(b, 0, 100) / 100 a = constrain(a !== undefined ? a : 100, 0, 100) / 100 const k = n => (n + h / 60) % 6 const f = n => b * (1 - s * max(0, min(k(n), 4 - k(n), 1))) return `rgba(${round(f(5) * 255)},${round(f(3) * 255)},${round(f(1) * 255)},${a})` } function _parseHex(hex) { const r = parseInt(hex.slice(1, 3), 16) const g = parseInt(hex.slice(3, 5), 16) const b = parseInt(hex.slice(5, 7), 16) return [r, g, b, 255] } function _rgbToHSB(r, g, b) { r /= 255; g /= 255; b /= 255 const mx = max(r, g, b), mn = min(r, g, b), d = mx - mn let h = 0, s = mx === 0 ? 0 : d / mx if (d !== 0) { if (mx === r) h = ((g - b) / d + 6) % 6 else if (mx === g) h = (b - r) / d + 2 else h = (r - g) / d + 4 h *= 60 } return [h, s * 100, mx * 100] } // Color object — stores RGBA internally class SColor { constructor(r, g, b, a) { this._r = r; this._g = g; this._b = b; this._a = a } toString() { return `rgba(${this._r},${this._g},${this._b},${this._a / 255})` } setAlpha(a) { this._a = constrain(round(a / 100 * 255), 0, 255) } } window.SColor = SColor function color(a, b, c, d) { if (a instanceof SColor) return a if (typeof a === 'string') { const [r, g, bl, al] = _parseHex(a) return new SColor(r, g, bl, al) } // HSB mode: (h, s, b [, a]) if (c !== undefined) { const css = _hsbToCSS(a, b, c, d !== undefined ? d : 100) const m = css.match(/[\d.]+/g) return new SColor(+m[0], +m[1], +m[2], round(+m[3] * 255)) } // single value → grayscale brightness const v = constrain(a, 0, 100) const bri = round(v / 100 * 255) if (b !== undefined) { // (gray, alpha) return new SColor(bri, bri, bri, round(constrain(b, 0, 100) / 100 * 255)) } return new SColor(bri, bri, bri, 255) } function red(c) { return c._r } function green(c) { return c._g } function blue(c) { return c._b } function hue(c) { return _rgbToHSB(c._r, c._g, c._b)[0] } function lerpColor(c1, c2, t) { return new SColor( round(lerp(c1._r, c2._r, t)), round(lerp(c1._g, c2._g, t)), round(lerp(c1._b, c2._b, t)), round(lerp(c1._a, c2._a, t)) ) } // ─── RESOLVE COLOR ARGS → CSS STRING ─── function _resolveColor(args) { if (args.length === 0) return null const a0 = args[0] if (a0 instanceof SColor) return a0.toString() if (typeof a0 === 'string') return a0 if (args.length >= 3) return _hsbToCSS(args[0], args[1], args[2], args[3]) // single value = grayscale brightness (0-100 in HSB), or >100 clamps to white const bri = constrain(a0, 0, 255) const v = round(bri / 100 * 255) const av = args[1] !== undefined ? constrain(args[1], 0, 100) / 100 : 1 return `rgba(${min(v, 255)},${min(v, 255)},${min(v, 255)},${av})` } // ─── GRAPHICS BUFFER ─── class PGraphics { constructor(w, h, mode) { this.width = w this.height = h this._isWebGL = (mode === WEBGL) this.canvas = document.createElement('canvas') this.canvas.width = w this.canvas.height = h if (this._isWebGL) { this._gl = this.canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true }) this.drawingContext = this._gl this._setupGL() } else { this.drawingContext = this.canvas.getContext('2d') this.drawingContext.imageSmoothingEnabled = false } this._rectMode = CORNER this._fillCSS = '#000000' this._strokeCSS = null this._strokeW = 1 this._doFill = true this._doStroke = true this._tintAlpha = 1 this._tintColor = null this._erasing = false this._shapeVerts = null this._shapeBeziers = null this._activeShader = null } // ── GL setup ── _setupGL() { const gl = this._gl // p5 vertex shader expects aPosition in 0-1 range (maps to clip space via *2-1) const verts = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]) const texCoords = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]) this._quadPos = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, this._quadPos) gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW) this._quadTex = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, this._quadTex) gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW) this._inputTex = gl.createTexture() } createShader(vertSrc, fragSrc) { const gl = this._gl function compile(type, src) { const s = gl.createShader(type) gl.shaderSource(s, src) gl.compileShader(s) if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error('Shader compile error:', gl.getShaderInfoLog(s)) } return s } const vs = compile(gl.VERTEX_SHADER, vertSrc) const fs = compile(gl.FRAGMENT_SHADER, fragSrc) const prog = gl.createProgram() gl.attachShader(prog, vs) gl.attachShader(prog, fs) gl.linkProgram(prog) if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error('Shader link error:', gl.getProgramInfoLog(prog)) } return { _prog: prog, _gl: gl, _uniforms: {}, setUniform(name, val) { gl.useProgram(prog) let loc = this._uniforms[name] if (!loc) { loc = gl.getUniformLocation(prog, name); this._uniforms[name] = loc } if (loc === null) return if (val instanceof PGraphics || (val && val.canvas)) { // Texture gl.activeTexture(gl.TEXTURE0) const pgTex = this._gl._boundTex || this._gl.createTexture() this._gl._boundTex = pgTex gl.bindTexture(gl.TEXTURE_2D, pgTex) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, val.canvas || val) gl.uniform1i(loc, 0) } else if (Array.isArray(val)) { if (val.length === 2) gl.uniform2f(loc, val[0], val[1]) else if (val.length === 3) gl.uniform3f(loc, val[0], val[1], val[2]) else if (val.length === 4) gl.uniform4f(loc, val[0], val[1], val[2], val[3]) } else if (typeof val === 'boolean') { gl.uniform1i(loc, val ? 1 : 0) } else { gl.uniform1f(loc, val) } } } } shader(s) { this._activeShader = s } // Draw fullscreen quad with current shader rect(x, y, w, h) { if (this._isWebGL && this._activeShader) { const gl = this._gl const prog = this._activeShader._prog gl.useProgram(prog) gl.viewport(0, 0, this.width, this.height) const posLoc = gl.getAttribLocation(prog, 'aPosition') if (posLoc >= 0) { gl.bindBuffer(gl.ARRAY_BUFFER, this._quadPos) gl.enableVertexAttribArray(posLoc) gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0) } const texLoc = gl.getAttribLocation(prog, 'aTexCoord') if (texLoc >= 0) { gl.bindBuffer(gl.ARRAY_BUFFER, this._quadTex) gl.enableVertexAttribArray(texLoc) gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0) } gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) return } // 2D rect — use path-based fill to match p5's behavior const ctx = this.drawingContext let rx = x, ry = y if (this._rectMode === CENTER) { rx = x - w / 2; ry = y - h / 2 } // Optimization: REPLACE + transparent fill = clearRect (safer across browsers) if (ctx.globalCompositeOperation === 'copy' && this._fillCSS === 'rgba(0,0,0,0)') { ctx.clearRect(rx, ry, w, h) } else { ctx.beginPath() ctx.rect(rx, ry, w, h) if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() } if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } } } // ── 2D Drawing ── pixelDensity() { return 1 } colorMode() { } // noop — always HSB internally noStroke() { this._doStroke = false; this._strokeCSS = null } noFill() { this._doFill = false } fill(...args) { this._doFill = true this._fillCSS = _resolveColor(args) } stroke(...args) { this._doStroke = true this._strokeCSS = _resolveColor(args) } strokeWeight(w) { this._strokeW = w; this.drawingContext.lineWidth = w } blendMode(m) { if (!this._isWebGL) this.drawingContext.globalCompositeOperation = m } rectMode(m) { this._rectMode = m } background(...args) { if (this._isWebGL) return const ctx = this.drawingContext ctx.save() ctx.setTransform(1, 0, 0, 1, 0, 0) if (args.length >= 4 && args[0] === 0 && args[1] === 0 && args[2] === 0 && args[3] === 0) { ctx.clearRect(0, 0, this.width, this.height) } else if (args[0] instanceof SColor) { ctx.fillStyle = args[0].toString() ctx.fillRect(0, 0, this.width, this.height) } else { ctx.fillStyle = _resolveColor(args) ctx.fillRect(0, 0, this.width, this.height) } ctx.restore() } clear() { if (this._isWebGL) { const gl = this._gl; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT) } else { const ctx = this.drawingContext; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, this.width, this.height); ctx.restore() } } push() { if (!this._isWebGL) { this.drawingContext.save() this._stateStack = this._stateStack || [] this._stateStack.push({ fill: this._fillCSS, stroke: this._strokeCSS, doFill: this._doFill, doStroke: this._doStroke, strokeW: this._strokeW, rectMode: this._rectMode, erasing: this._erasing, tintAlpha: this._tintAlpha, tintColor: this._tintColor }) } } pop() { if (!this._isWebGL) { this.drawingContext.restore() if (this._stateStack && this._stateStack.length) { const s = this._stateStack.pop() this._fillCSS = s.fill; this._strokeCSS = s.stroke; this._doFill = s.doFill; this._doStroke = s.doStroke; this._strokeW = s.strokeW; this._rectMode = s.rectMode; this._erasing = s.erasing; this._tintAlpha = s.tintAlpha; this._tintColor = s.tintColor } } } translate(x, y) { if (!this._isWebGL) this.drawingContext.translate(x, y) } rotate(a) { if (!this._isWebGL) this.drawingContext.rotate(a) } scale(x, y) { if (!this._isWebGL) this.drawingContext.scale(x, y !== undefined ? y : x) } // Erase mode erase() { this._erasing = true this._prevComposite = this.drawingContext.globalCompositeOperation this.drawingContext.globalCompositeOperation = 'destination-out' } noErase() { this._erasing = false this.drawingContext.globalCompositeOperation = this._prevComposite || 'source-over' } // ── Shapes ── beginShape() { this._shapeVerts = []; this._firstVert = true } vertex(x, y) { if (!this._shapeVerts) return this._shapeVerts.push({ type: 'v', x, y }) } bezierVertex(x2, y2, x3, y3, x4, y4) { if (!this._shapeVerts) return this._shapeVerts.push({ type: 'b', x2, y2, x3, y3, x4, y4 }) } curveVertex(x, y) { if (!this._shapeVerts) return this._shapeVerts.push({ type: 'c', x, y }) } endShape(mode) { if (!this._shapeVerts || this._shapeVerts.length === 0) return const ctx = this.drawingContext ctx.beginPath() // Separate handling for curve vertices const allCurve = this._shapeVerts.every(v => v.type === 'c') if (allCurve && this._shapeVerts.length >= 4) { this._drawCatmullRom(ctx, this._shapeVerts.map(v => ({ x: v.x, y: v.y }))) } else { let first = true for (const v of this._shapeVerts) { if (v.type === 'v') { if (first) { ctx.moveTo(v.x, v.y); first = false } else ctx.lineTo(v.x, v.y) } else if (v.type === 'b') { ctx.bezierCurveTo(v.x2, v.y2, v.x3, v.y3, v.x4, v.y4) } } } if (mode === CLOSE || mode === 'close') ctx.closePath() if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() } if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } this._shapeVerts = null } _drawCatmullRom(ctx, pts) { // Catmull-Rom → cubic bezier conversion // p5 curveVertex expects: first and last points are control-only if (pts.length < 4) return ctx.moveTo(pts[1].x, pts[1].y) for (let i = 0; i < pts.length - 3; i++) { const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3] ctx.bezierCurveTo( p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6, p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6, p2.x, p2.y ) } } ellipse(x, y, w, h) { if (h === undefined) h = w const ctx = this.drawingContext ctx.beginPath() ctx.ellipse(x, y, w / 2, h / 2, 0, 0, TWO_PI) if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() } if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } } circle(x, y, d) { this.ellipse(x, y, d, d) } line(x1, y1, x2, y2) { const ctx = this.drawingContext ctx.beginPath() ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } } triangle(x1, y1, x2, y2, x3, y3) { const ctx = this.drawingContext ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath() if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() } if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } } quad(x1, y1, x2, y2, x3, y3, x4, y4) { const ctx = this.drawingContext ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.lineTo(x4, y4); ctx.closePath() if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() } if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() } } // ── Image / tint ── image(src, x, y, w, h) { if (!src) return const ctx = this.drawingContext const prevAlpha = ctx.globalAlpha // ALWAYS set globalAlpha — prevent stale values from leaking across operations ctx.globalAlpha = this._tintAlpha // Handle slice references from get() — avoids creating temp canvases if (src._isSlice) { ctx.drawImage(src.canvas, src._sx, src._sy, src._sw, src._sh, x || 0, y || 0, w || src._sw, h || src._sh) ctx.globalAlpha = prevAlpha return } const srcCanvas = (src instanceof PGraphics) ? src.canvas : src if (this._tintColor && this._tintAlpha > 0) { // Color tint: draw to temp, multiply with color if (!this._tintCanvas || this._tintCanvas.width !== this.width || this._tintCanvas.height !== this.height) { this._tintCanvas = document.createElement('canvas') this._tintCanvas.width = this.width; this._tintCanvas.height = this.height this._tintCtx = this._tintCanvas.getContext('2d') } const tc = this._tintCtx tc.clearRect(0, 0, this.width, this.height) tc.globalCompositeOperation = 'source-over' tc.globalAlpha = 1.0 tc.drawImage(srcCanvas, x || 0, y || 0, w || srcCanvas.width || this.width, h || srcCanvas.height || this.height) tc.globalCompositeOperation = 'source-atop' tc.fillStyle = this._tintColor tc.fillRect(0, 0, this.width, this.height) ctx.drawImage(this._tintCanvas, 0, 0) } else if (w !== undefined && h !== undefined) { ctx.drawImage(srcCanvas, x || 0, y || 0, w, h) } else { ctx.drawImage(srcCanvas, x || 0, y || 0) } ctx.globalAlpha = prevAlpha } tint(...args) { if (args.length === 2 && args[0] === 255) { // tint(255, alpha) — just alpha this._tintAlpha = args[1] / 255 this._tintColor = null } else if (args.length === 4) { // tint(r, g, b, a) this._tintColor = `rgb(${args[0]},${args[1]},${args[2]})` this._tintAlpha = args[3] / 255 } else if (args.length === 1) { this._tintAlpha = args[0] / 255 this._tintColor = null } } noTint() { this._tintAlpha = 1; this._tintColor = null } // p5's get(x,y,w,h) — return lightweight slice reference // Avoids creating a new canvas for every call (critical for corrupt effect performance) get(x, y, w, h) { const srcCanvas = this.canvas return { canvas: srcCanvas, _isSlice: true, _sx: x, _sy: y, _sw: w, _sh: h, width: w, height: h } } // p5 color() on buffer instance color(...args) { return color(...args) } lerpColor(c1, c2, t) { return lerpColor(c1, c2, t) } // Cleanup — p5 removes from DOM, we just release the canvas remove() { if (this._isWebGL && this._gl) { const ext = this._gl.getExtension('WEBGL_lose_context') if (ext) ext.loseContext() } this.canvas.width = 0 this.canvas.height = 0 } } window.PGraphics = PGraphics // ─── MAIN CANVAS + LIFECYCLE ─── var _mainPG = null var _mainCanvas = null var _displayCanvas = null var _displayCtx = null var width = 0, height = 0 var frameCount = 0 var _targetFPS = 60 var _frameDuration = 1000 / 60 var _lastFrame = 0 var _running = true var key = '' // When null, millis() reads real wall-clock time (live views). The capture // fast-forward (see the DOMContentLoaded handler) sets this to a virtual // accumulator so millis() and frameCount advance in lockstep. var _virtualClock = null function createCanvas(w, h) { _mainPG = new PGraphics(w, h) _mainCanvas = _mainPG.canvas _mainCanvas.id = 'defaultCanvas0' // The 5:7 render canvas stays off-DOM. The DOM shows a square display // canvas holding the top 1:1 crop (blitted in _presentFrame), so // right-click copy / drag-out yields the same square the viewer sees. _displayCanvas = document.createElement('canvas') _displayCanvas.width = w _displayCanvas.height = w _displayCtx = _displayCanvas.getContext('2d') _displayCtx.imageSmoothingEnabled = false document.body.appendChild(_displayCanvas) width = w; height = h _mainPG.drawingContext.imageSmoothingEnabled = false window.drawingContext = _mainPG.drawingContext return { elt: _mainCanvas, canvas: _mainCanvas } } // Present the finished frame: copy the top square of the offscreen render // canvas onto the DOM display canvas. function _presentFrame() { if (!_displayCtx || !_mainCanvas) return _displayCtx.drawImage(_mainCanvas, 0, 0, width, width, 0, 0, width, width) } function createGraphics(w, h, mode) { return new PGraphics(w, h, mode) } function pixelDensity() { return 1 } function frameRate(fps) { _targetFPS = fps; _frameDuration = 1000 / fps } function colorMode() { } // HSB always function millis() { return _virtualClock === null ? performance.now() : _virtualClock } // Global drawing proxies — delegate to main canvas function background(...a) { _mainPG.background(...a) } function image(...a) { _mainPG.image(...a) } function fill(...a) { _mainPG.fill(...a) } function noFill() { _mainPG.noFill() } function stroke(...a) { _mainPG.stroke(...a) } function noStroke() { _mainPG.noStroke() } function strokeWeight(w) { _mainPG.strokeWeight(w) } function push() { _mainPG.push() } function pop() { _mainPG.pop() } function translate(x, y) { _mainPG.translate(x, y) } function rotate(a) { _mainPG.rotate(a) } function blendMode(m) { _mainPG.blendMode(m) } function rectMode(m) { _mainPG.rectMode(m) } function beginShape() { _mainPG.beginShape() } function vertex(x, y) { _mainPG.vertex(x, y) } function endShape(m) { _mainPG.endShape(m) } function rect(x, y, w, h) { _mainPG.rect(x, y, w, h) } function ellipse(x, y, w, h) { _mainPG.ellipse(x, y, w, h) } function circle(x, y, d) { _mainPG.circle(x, y, d) } function line(x1, y1, x2, y2) { _mainPG.line(x1, y1, x2, y2) } // ─── DRAW LOOP ─── function _loop(ts) { if (!_running) return if (ts - _lastFrame >= _frameDuration) { _lastFrame = ts try { if (typeof draw === 'function') { frameCount++; draw(); _presentFrame() } } catch (e) { console.error('Draw error:', e) } } requestAnimationFrame(_loop) } window.addEventListener('DOMContentLoaded', () => { try { if (typeof setup === 'function') setup() } catch (e) { console.error('Setup error:', e) } // Capture fast-forward: under the capture UA, advance straight to the // snapshot frame synchronously instead of waiting ~4s of real-time RAF. // The particle systems accumulate per frame, so we still run every step — // just back-to-back with no throttle. The virtual clock ticks +one frame // per step so millis()-driven motion (breathing, crack cycle) lands at the // same phase a live viewer sees at the snapshot frame, deterministically // and with no RAF jitter. The draw loop's own freeze/ready logic fires on // the final step. Live/collector views skip this branch and animate normally. if (typeof window.$art !== 'undefined' && window.$art.captureMode) { const target = (typeof SNAPSHOT_FRAME === 'number') ? SNAPSHOT_FRAME : 96 _virtualClock = 0 while (frameCount < target && typeof draw === 'function') { _virtualClock += _frameDuration frameCount++ try { draw() } catch (e) { console.error('Draw error:', e) } } _presentFrame() if (typeof DEBUG !== 'undefined' && DEBUG) { console.log(`%c⏩ CAPTURE — fast-forwarded to frame ${frameCount}, frozen`, 'color:#00FFFF; font-weight:bold;') } // The canvas now holds the fully-rendered hero frame. Stop here and do // NOT restart RAF: freezeMode gates the update/breath code but not the // per-frame frameCount++, so any uncached display-path read of // frameCount/millis()/random() (e.g. eye flicker, aura shimmer) would // keep animating on subsequent ticks. Leaving the loop off makes the // snapshot a genuinely static single frame. _running = false return } requestAnimationFrame(_loop) }) window.addEventListener('keydown', (e) => { key = e.key if (e.key === ' ' || e.key === 'Escape') e.preventDefault() if (typeof keyPressed === 'function') keyPressed() }) </script> <style> html, body { margin: 0; padding: 0; height: 100%; width: 100%; background: #0b0b0b; overflow: hidden; display: flex; align-items: center; justify-content: center; } canvas { display: block; width: min(100vw, 100vh) !important; height: min(100vw, 100vh) !important; image-rendering: pixelated; image-rendering: crisp-edges; -webkit-image-rendering: pixelated; } </style> </head> <body> <!-- TL Gen Art — rendering pipeline only. Satari owns its own seeding/RNG; this provides just the snapshot + traits DOM hooks that survive in a captured HTML snapshot (Cloudflare Browser Rendering). No seeding/randomness here. --> <script> const $art = (function () { function writeHiddenJSON(id, data) { let el = document.getElementById(id); if (!el) { el = document.createElement("script"); el.type = "application/json"; // not executed, not rendered, survives in HTML el.id = id; (document.body || document.documentElement).appendChild(el); } el.textContent = JSON.stringify(data); return el; } let currentTraits = null; // { Palette: "Sunset", Layers: 5 } -> OpenSea [{trait_type, value}, ...] function setTraits(traits) { if (!traits || typeof traits !== "object" || Array.isArray(traits)) throw new Error("$art.setTraits: expects a plain object of { name: value }"); currentTraits = Object.keys(traits).map((k) => ({ trait_type: k, value: traits[k] })); writeHiddenJSON("art-traits", currentTraits); return currentTraits; } // Capture environment detection. Cloudflare Browser Rendering is configured to send // this sentinel user agent; live collector views never match. Keep in sync with infra. const CAPTURE_UA = "tl-gen-art"; const captureMode = typeof navigator !== "undefined" && new RegExp(CAPTURE_UA).test(navigator.userAgent || ""); // Append a hidden #art-snapshot-ready marker so Cloudflare can waitForSelector on it. // Optional freeze callback runs ONLY under the capture UA (live views keep animating). function snapshot(onCapture) { if (captureMode && typeof onCapture === "function") onCapture(); let el = document.getElementById("art-snapshot-ready"); if (!el) { el = document.createElement("div"); el.id = "art-snapshot-ready"; el.style.display = "none"; (document.body || document.documentElement).appendChild(el); } return el; } return { setTraits, getTraits: () => currentTraits, snapshot, captureMode }; })(); window.$art = $art; </script> <script> let CURRENT_SEED = null const DEBUG = true // Set false for production — disables console logging let freezeMode = false // Snapshot: under the capture UA, settle to this frame, freeze, then mark ready. // 96 frames = the same 4s of formation (at the live 24fps) a viewer perceives. // Live views reach it in real time; the capture path fast-forwards through the // 96 accumulating frames synchronously on a virtual clock (see the // DOMContentLoaded handler), so both frame-count structure and millis()-driven // motion land at exactly the 4-second phase — instantly and deterministically. let _snapshotMarked = false const SNAPSHOT_FRAME = 96 let bwMode = false // Driven by Etched palette trait; set in setup/regenerate. const ASPECT_RATIO = 1.4 // 5:7 internal render — displayed as 1:1 top crop let FORM_BREATH_HZ = 0.4 let FORM_BREATH_AMP = 10 let BREATH_DELAY_PART1 = 0.30 let BREATH_DELAY_MASK = 0.20 let FILL_ANIMATION_SPEED_MULT = 2.2 const QUALITY_TIERS = [ { minWidth: 0, canvasW: 200, fps: 6, maxAnims: 5, gridCols: 40, noise: false }, { minWidth: 200, canvasW: 300, fps: 12, maxAnims: 10, gridCols: 60, noise: false }, { minWidth: 400, canvasW: 400, fps: 18, maxAnims: 15, gridCols: 75, noise: true }, { minWidth: 600, canvasW: 500, fps: 24, maxAnims: 20, gridCols: 86, noise: true }, ] let _currentTier = null let _tierMaxAnims = 20 let _tierGridDensity = 86 let _tierNoiseOverride = null // null = use trait value, false = force off let _pendingTier = null // Deferred tier change — applied at start of next frame function headCenterY() { return width / 2 } // center of head zone let _designScale = 1 // width / 750 — used to scale pixel values to current canvas size let _cachedPart2Offset = 0 let _cachedPart1Offset = 0 let _cachedMaskOffset = 0 let _cachedEyeOpacity = 0 let _cachedOmniaCol = null let _cachedShaderTime = 0 let _breathStartMillis = null function seedFromString(str) { let h = 2166136261 >>> 0 for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i) h = Math.imul(h, 16777619) } h += h << 13 h ^= h >>> 7 h += h << 3 h ^= h >>> 17 h += h << 5 return h >>> 0 } function randomU32() { if (window.crypto && crypto.getRandomValues) { const a = new Uint32Array(1) crypto.getRandomValues(a) return a[0] >>> 0 } return (Math.random() * 0xffffffff) >>> 0 } // ─── SEED SOURCE ─── // Priority chain: // 1. ?tokenId=<n>&blockhash=<hex> (production render — both required) // 2. ?seed=<uint32> (manual preview / debugging) // 3. randomU32() (local fallback) function readSeed() { const params = new URLSearchParams(window.location.search) // 1. Production render: seed deterministically from the token id + block hash // injected via query params. Both must be present. We hash the combined // string into the existing uint32 RNG pipeline so the same // (tokenId, blockhash) pair always yields the same seed. if (params.has("tokenId") && params.has("blockhash")) { const tokenId = params.get("tokenId") const blockhash = params.get("blockhash") CURRENT_SEED = seedFromString(tokenId + "_" + blockhash) return } // 2. Manual override via URL query. if (params.has("seed")) { CURRENT_SEED = Number(params.get("seed")) >>> 0 return } // 3. Random fallback for local preview. CURRENT_SEED = randomU32() } function applySeed() { randomSeed(CURRENT_SEED) noiseSeed(CURRENT_SEED) } function mulberry32(a) { return () => { let t = (a += 0x6d2b79f5) | 0 t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return (t ^ (t >>> 14)) >>> 0 // raw uint32, not float } } function makeTraitRNG(tag) { return mulberry32(seedFromString(tag + "_" + CURRENT_SEED)) } function pickWeightedWith(rng, table) { const raw = rng() let sum = 0 for (const [, w] of table) sum += w let r = Math.floor(raw * sum / 4294967296), acc = 0 for (const [v, w] of table) { acc += w if (r < acc) return v } return table.at(-1)[0] } // ─── POST-MINT MUTATIONS ─── // Pieces accumulate state after mint via the kill mechanic. Mutations are // fetched from external storage (Transient Labs custom tooling at production; // returns zeros when unavailable for local preview / fresh-mint state). // Shape: // { // killCount: <int>, // total kills → Slain (every kill ticks +1) // rememberingCount: <int>, // trait-inheritance kills → Remembering // sameHouseKillCount: <int>, // in-house kills → derives Doctrine (with killCount) // palette?, energy?, sight?, iris?, scent?, voice?, ornament? // } // Mutation keys accepted as individual, readable URL params (dev / sacrifice // preview), e.g. ?killCount=1&rememberingCount=1&sameHouseKillCount=2&palette=Toxin // Graftable traits: Palette, Aura, Energy, Sight, Iris, Scent, Voice, Ornament. // killCount / rememberingCount / sameHouseKillCount are automatic kill outcomes, // not stealable traits. Doctrine is derived from killCount + sameHouseKillCount (see below). const MUT_PARAM_KEYS = ["killCount", "rememberingCount", "sameHouseKillCount", "palette", "aura", "energy", "sight", "iris", "scent", "voice", "ornament"] function getMutations() { // Priority order: // 1. tokenData.mutations (production: on-chain values via Transient Labs) // 2. individual ?killCount=&scent=&palette=… params (readable dev/sacrifice overrides) // 3. fresh-mint defaults (zeros) if (typeof tokenData !== "undefined" && tokenData && tokenData.mutations) { return _normalizeMutations(tokenData.mutations) } // URL-param fallbacks (dev/tester only; production never uses these paths). if (typeof window !== "undefined" && window.location && window.location.search) { const usp = new URLSearchParams(window.location.search) // 2. Flat per-trait params. _normalizeMutations coerces the string values // ("1" -> 1 via | 0; trait strings pass through unchanged). if (MUT_PARAM_KEYS.some((k) => usp.has(k))) { const flat = {} for (const k of MUT_PARAM_KEYS) { if (usp.has(k)) flat[k] = usp.get(k) } return _normalizeMutations(flat) } } return _normalizeMutations({}) } // Canonical values for graftable traits, so override values are accepted // case-insensitively (e.g. sight=mono → "Mono") — the renderer matches these // strings exactly. Keep in sync with the trait tables in buildTraits(). Energy // and Aura carry a few internal names the renderer keys on; MUT_PUBLISHED_TO_INTERNAL // (below) also accepts their collector-facing names. Unknown values pass through. const MUT_CANONICAL = { palette: ["Flux", "Acid", "Surge", "Infra", "Toxin", "Auric", "Jazz", "Corrupt", "Vapor", "Tropic", "Wraith", "Riptide", "Petal", "Sage", "Lilac", "Crimson", "Abyss", "Canopy", "Amber", "Haze", "Current", "Flare", "Coast", "Flora", "Astra", "Dune", "Polar", "Clash", "Royal", "Axis", "Blood", "Signal", "Dual", "Stark", "Etched"], aura: ["Calm", "Radiant", "Skyfall", "Melt", "CorruptMask", "CorruptRealm", "Seeds", "Motes", "Prisms", "Vent", "Kintsugi"], energy: ["Supernova", "ExpandingCircles", "ExpandingSquares", "VerticalStripes", "Gradient", "HorizontalStripes", "WavyLines", "Sparkles", "VoronoiCells", "Shockwave"], sight: ["Core", "Echo", "Verge", "Slate", "Vert", "Signal", "Pierce", "Cross", "Cut", "Lune", "Mono", "Teardrop"], iris: ["Null", "Abyss", "Fury", "Plasma", "Omnia"], scent: ["Sealed", "Aero", "Sego", "Duos", "Jewel", "Dot", "Aexo", "Tri"], voice: ["Still", "Prowl", "Forge", "Sunder", "Hollow", "Devour", "Viper", "Bind", "Downfall", "Stun", "Grate", "Ease"], ornament: ["Bare", "Links", "Amp", "Crest", "Bestia", "Root", "Gauge", "Thorns", "Tandem", "Spike"], // Doctrine is not an input trait — it is derived from killCount + sameHouseKillCount. } // Energy and Aura are published to collectors under names that differ from // the renderer's internal values. Production passes the collector-facing // (on-chain) names, so accept those here and translate to the internal // names the renderer keys on. Internal names still resolve via MUT_CANONICAL. const MUT_PUBLISHED_TO_INTERNAL = { energy: { Pulse: "ExpandingCircles", Lattice: "ExpandingSquares", Beam: "VerticalStripes", Bleed: "Gradient", Strata: "HorizontalStripes", Frequency: "WavyLines", Spark: "Sparkles", Plasma: "VoronoiCells", }, aura: { Broken: "CorruptMask", Fragments: "CorruptRealm" }, } function _canonMut(trait, value) { if (typeof value !== "string") return value const aliases = MUT_PUBLISHED_TO_INTERNAL[trait] if (aliases) { const hit = Object.keys(aliases).find((k) => k.toLowerCase() === value.toLowerCase()) if (hit) return aliases[hit] } const list = MUT_CANONICAL[trait] if (!list) return value const lc = value.toLowerCase() return list.find((v) => v.toLowerCase() === lc) || value } function _normalizeMutations(m) { return { // killCount / rememberingCount / sameHouseKillCount are the only accepted // counter params (production sends exactly these). String values from // URLSearchParams ("2") coerce to int via `| 0`; absent → 0. slain: (m.killCount | 0) >>> 0, remembering: (m.rememberingCount | 0) >>> 0, sameHouseKillCount: (m.sameHouseKillCount | 0) >>> 0, palette: _canonMut("palette", m.palette) || null, aura: _canonMut("aura", m.aura) || null, energy: _canonMut("energy", m.energy) || null, sight: _canonMut("sight", m.sight) || null, iris: _canonMut("iris", m.iris) || null, scent: _canonMut("scent", m.scent) || null, voice: _canonMut("voice", m.voice) || null, ornament: _canonMut("ornament", m.ornament) || null, } } // Doctrine is derived from kill counts (not a graftable trait). With no kills it // is Idle. Otherwise it reflects the balance between out-of-house and in-house // kills: diff = differentHouseKillCount - sameHouseKillCount. // diff >= +5 → Crusader, >= +2 → Devoted, |diff| < 2 → Sovereign, // <= -2 → Dissenting, <= -5 → Heretic. function doctrineFromKills(killCount, sameHouseKillCount) { if (killCount <= 0) return "Idle" const differentHouseKillCount = killCount - sameHouseKillCount const diff = differentHouseKillCount - sameHouseKillCount if (diff >= 5) return "Crusader" if (diff >= 2) return "Devoted" if (diff <= -5) return "Heretic" if (diff <= -2) return "Dissenting" return "Sovereign" } // Apply mutation overlay to a freshly-built traits object. Respects compatibility // rules: incompatible inheritances are marked "(inert)" in the trait field so // they show in metadata but don't break render assumptions. function applyMutations(result, mut) { result.slain = mut.slain result.remembering = mut.remembering // Palette: re-graft as-is (including Etched). // Palette graft: when adopting Etched, the underlying _basePalette stays as // the killer's own original base (preserves their original ash beneath the // new Etched skin). When grafting a non-Etched palette, the killer's // _basePalette is cleared (since they're no longer Etched). if (mut.palette) { if (mut.palette === "Etched") { // Adopt Etched override. _basePalette = whatever killer already had as // their underlying palette: either their existing _basePalette (if they // were already Etched), or their current paletteName (if they weren't). if (!result._basePalette) { result._basePalette = result.paletteName } result.paletteName = "Etched" } else { // Adopt a non-Etched palette: clear any Etched state, set new palette. result._basePalette = null result.paletteName = mut.palette } } // Energy → fillVariant if (mut.energy) result.fillVariant = mut.energy // Sight, Iris, Scent, Voice: free swaps if (mut.sight) result.sight = mut.sight if (mut.iris) result.iris = mut.iris if (mut.scent) result.scent = mut.scent // Aura: free swap. Note: the Helios+Radiant override is a renderer invariant — // grafting Radiant onto a Helios killer converts to Seeds or Calm here too, // matching mint-time behavior. if (mut.aura) { let newAura = mut.aura if (result.displayedArchetype === "Helios" && newAura === "Radiant") { // Deterministic flip — use the seed's existing entropy via a fresh tag. const _r = makeTraitRNG("auraGraftHeliosRadiant") newAura = _r() < 2147483648 ? "Seeds" : "Calm" } result.aura = newAura } if (mut.voice) result.voice = mut.voice // Doctrine: derived from kill counts (Idle when no kills; otherwise the balance // of out-of-house vs in-house kills — see doctrineFromKills). result.doctrine = doctrineFromKills(mut.slain, mut.sameHouseKillCount) // Ornament: respect bestiaCompatible / Vanta-Crest rules if (mut.ornament) { const bestiaCompatible = ["Havoc", "Sentinel", "Apex", "Rogue", "Riven", "Guard", "Nomad", "Blade", "Prime"] if (mut.ornament === "Bestia" && !bestiaCompatible.includes(result.displayedArchetype)) { result.ornament = "Bestia (inert)" } else if (mut.ornament === "Crest" && result.displayedArchetype === "Vanta") { result.ornament = "Crest (inert)" } else { result.ornament = mut.ornament } } return result } function buildTraits() { const rArch = makeTraitRNG("archetype") const rPal = makeTraitRNG("palette") const rEye = makeTraitRNG("sight") const rMou = makeTraitRNG("voice") const rNose = makeTraitRNG("nose") const rOrn = makeTraitRNG("ornament") const rCut = makeTraitRNG("cutouts") const rRealm = makeTraitRNG("realm") const rOr = makeTraitRNG("orient") const rEdge = makeTraitRNG("edgeStyle") const rEyeColor = makeTraitRNG("eyeColor") const rOrigin = makeTraitRNG("originTopology") const rGrowth = makeTraitRNG("growthProfile") const rBlend = makeTraitRNG("blendSchema") const rSize = makeTraitRNG("sizeEnvelope") const rSpawn = makeTraitRNG("spawnRhythm") const rFillVariant = makeTraitRNG("fillVariant") const rOrbit = makeTraitRNG("orbitingElements") const rOrbitSpeed = makeTraitRNG("orbitalSpeed") const rWavyDir = makeTraitRNG("wavyDirection") const rExpandRotation = makeTraitRNG("expandingRotation") const rExpandPersist = makeTraitRNG("expandingPersist") const rApplyNoise = makeTraitRNG("applyNoise") const rInkMode = makeTraitRNG("inkMode") const rSymbolSize = makeTraitRNG("symbolSize") const rSpacecraft = makeTraitRNG("spacecraft") let paletteName = pickWeightedWith(rPal, [ ["Flux", 10], ["Acid", 10], ["Surge", 10], ["Infra", 10], ["Toxin", 10], ["Auric", 10], ["Jazz", 10], ["Corrupt", 10], ["Vapor", 10], ["Tropic", 10], ["Wraith", 10], ["Riptide", 10], ["Petal", 10], ["Sage", 10], ["Lilac", 10], ["Crimson", 10], ["Abyss", 10], ["Canopy", 10], ["Amber", 10], ["Haze", 10], ["Current", 10], ["Flare", 10], ["Coast", 10], ["Flora", 10], ["Astra", 10], ["Dune", 10], ["Polar", 10], ["Clash", 10], ["Royal", 10], ["Axis", 10], ["Bl