0x77b35947…cc7dsent to0xc3e1f0dd…9515·#25,613,196·view on Etherscan
ood", 10],
["Signal", 10],
["Dual", 10],
["Stark", 10],
])
let eyeColor = pickWeightedWith(rEyeColor, [
["Null", 15],
["Abyss", 80],
["Fury", 15],
["Plasma", 15],
["Omnia", 5],
])
// monochromeList — palettes that force the shader into single-hue tonal mode.
// Trimmed based on rating data: Sage/Petal/Crimson/Amber removed (these scored
// poorly as mono; they'll now go through the full complementary-color path).
const monochromeList = [
"Lilac", "Abyss", "Canopy",
"Haze", "Current",
"Riptide",
"Stark"
]
// ─── ARCHETYPE PICKER ───
// 24 total: 21 Mono (weight 27 each, ~4.523% each) + 3 Conflux (weight 10 each, ~1.675% each)
// Each archetype maps to one House (Codex/Forge/Helix) × one Role (Leader/Noble/Guard/Spy/
// Warrior/Scout/Chaos/Conflux). See HOUSE_BY_ARCHETYPE and ROLE_BY_ARCHETYPE below.
let archetype = pickWeightedWith(rArch, [
// Leader role
["Vanta", 27], ["Dux", 27], ["Emperor", 27],
// Noble role
["Seeker", 27], ["Arbiter", 27], ["Armatus", 27],
// Guard role
["Warden", 27], ["Bulwark", 27], ["Guard", 27],
// Spy role
["Shade", 27], ["Prime", 27], ["Apex", 27],
// Warrior role
["Sentinel", 27], ["Riven", 27], ["Blade", 27],
// Scout role
["Rogue", 27], ["Ferox", 27], ["Nomad", 27],
// Chaos role
["Brute", 27], ["Havoc", 27], ["Helios", 27],
// Conflux role (rare)
["Colossus", 10], ["Titan", 10], ["Goliath", 10],
])
const displayedArchetype = archetype
// ─── HOUSE × ROLE LOOKUP ───
// Every archetype belongs to exactly one House (Codex/Forge/Helix) and one Role
// (Leader/Noble/Guard/Spy/Warrior/Scout/Chaos/Conflux). 24 archetypes = 8 roles × 3 houses.
// Codex (Red) — Intelligence, Wisdom, Science
// Forge (Green) — Strength, brute force, physical power
// Helix (Blue) — Power (political, spiritual)
const HOUSE_BY_ARCHETYPE = {
// Codex
Vanta: "Codex", Seeker: "Codex", Warden: "Codex", Shade: "Codex",
Sentinel: "Codex", Rogue: "Codex", Brute: "Codex", Colossus: "Codex",
// Forge
Dux: "Forge", Arbiter: "Forge", Bulwark: "Forge", Prime: "Forge",
Riven: "Forge", Ferox: "Forge", Havoc: "Forge", Titan: "Forge",
// Helix
Emperor: "Helix", Armatus: "Helix", Guard: "Helix", Apex: "Helix",
Blade: "Helix", Nomad: "Helix", Helios: "Helix", Goliath: "Helix",
}
const ROLE_BY_ARCHETYPE = {
Vanta: "Leader", Dux: "Leader", Emperor: "Leader",
Seeker: "Noble", Arbiter: "Noble", Armatus: "Noble",
Warden: "Guard", Bulwark: "Guard", Guard: "Guard",
Shade: "Spy", Prime: "Spy", Apex: "Spy",
Sentinel: "Warrior", Riven: "Warrior", Blade: "Warrior",
Rogue: "Scout", Ferox: "Scout", Nomad: "Scout",
Brute: "Chaos", Havoc: "Chaos", Helios: "Chaos",
Colossus: "Conflux", Titan: "Conflux", Goliath: "Conflux",
}
const house = HOUSE_BY_ARCHETYPE[archetype]
const role = ROLE_BY_ARCHETYPE[archetype]
// Count is derived: Conflux archetypes are the only Conflux-count pieces.
const count = role === "Conflux" ? "Conflux" : "Mono"
let eyes = pickWeightedWith(rEye, [
["Core", 10],
["Echo", 10],
["Verge", 10],
["Slate", 10],
["Vert", 10],
["Signal", 10],
["Pierce", 10],
["Cross", 10],
["Cut", 10],
["Lune", 10],
["Mono", 10],
["Teardrop", 10],
])
let orbitingElements = pickWeightedWith(rOrbit, [
["Calm", 10],
["Radiant", 10],
["Skyfall", 10],
["Melt", 10],
["CorruptMask", 10],
["CorruptRealm", 10],
["Seeds", 10],
["Motes", 10],
["Prisms", 10],
["Vent", 10],
["Kintsugi", 10],
])
let mouth = pickWeightedWith(rMou, [
["Still", 10],
["Prowl", 10],
["Forge", 10],
["Sunder", 10],
["Hollow", 10],
["Devour", 10],
["Viper", 10],
["Bind", 10],
["Downfall", 10],
["Stun", 10],
["Grate", 10],
["Ease", 10],
])
let nose = pickWeightedWith(rNose, [
["Sealed", 10],
["Aero", 10],
["Sego", 10],
["Duos", 10],
["Jewel", 10],
["Dot", 10],
["Aexo", 10],
["Tri", 10],
])
let ornament = pickWeightedWith(rOrn, [
["Bare", 10],
["Links", 10],
["Amp", 10],
["Crest", 10],
["Bestia", 10],
["Root", 10],
["Gauge", 10],
["Thorns", 10],
["Tandem", 10],
["Spike", 10]
])
let hollows = pickWeightedWith(rCut, [
["Whole", 10],
["Cleave", 10],
["Mark", 10],
["Maw", 10],
])
let realm = pickWeightedWith(rRealm, [
["Halo", 2],
["Aero", 2],
["Rose", 2],
["Mint", 2],
["Mist", 1],
["Rise", 1],
["Fallen", 10],
["Shift", 10],
["Radiant", 10],
["Facet", 10],
["Ascend", 10],
["Descend", 10],
])
let edgeStyle = pickWeightedWith(rEdge, [
["Curved", 35], ["Straight", 25], ["Faceted", 30],
])
let orientation = pickWeightedWith(rOr, [
["Axis-locked", 80], ["Free", 20],
])
let fillVariant = pickWeightedWith(rFillVariant, [
["Supernova", 10],
["ExpandingCircles", 10],
["ExpandingSquares", 10],
["VerticalStripes", 10],
["Gradient", 10],
["HorizontalStripes", 10],
["WavyLines", 10],
["Sparkles", 10],
["VoronoiCells", 10],
["Shockwave", 10],
])
if (paletteName === "Wraith" && eyeColor === "Null") {
eyeColor = rEyeColor() < 2147483648 ? "Abyss" : "Fury"
}
if (paletteName === "Crimson" && eyeColor === "Fury") {
eyeColor = rEyeColor() < 2147483648 ? "Null" : "Abyss"
}
if (archetype === "Helios" && orbitingElements === "Radiant") {
orbitingElements = rOrbit() < 2147483648 ? "Seeds" : "Calm"
}
if (count === "Conflux" && eyes === "Mono") {
eyes = rEye() < 2147483648 ? "Core" : "Slate"
}
if (mouth === "Prowl") {
nose = "Sealed"
}
if (nose === "Sego" && mouth === "Grate") {
nose = rNose() < 2147483648 ? "Aero" : "Sealed"
}
// ─── ETCHED PALETTE OVERRIDE ───
// 5% chance the rolled palette gets replaced by "Etched" — a B&W ink treatment.
// Original palette is stored on _basePalette so render logic can still
// dispatch the underlying palette's behavior (Wraith void, Crimson-specific
// archetype tweaks, etc).
// Threshold: 0.05 * 2^32 = 214748365 (integer-RNG parity for Solidity).
let _basePalette = null
if (rInkMode() < 214748365) {
_basePalette = paletteName
paletteName = "Etched"
}
const bestiaCompatible = ["Havoc", "Sentinel", "Apex", "Rogue", "Riven", "Guard", "Nomad", "Blade", "Prime"]
if (ornament === "Bestia" && !bestiaCompatible.includes(displayedArchetype)) {
ornament = "Bare"
}
if (ornament === "Crest" && displayedArchetype === "Vanta") {
ornament = "Bare"
}
// ─── NON-VISUAL TRAIT — Spacecraft ───
// Doctrine is not rolled here — it is derived from the kill counts in
// applyMutations() (0 kills at fresh mint → Idle).
// Spacecraft: the vessel a citizen arrived on. Weights chosen to hit 54.9 / 20.1 / 13.8 / 11.3 %.
const spacecraft = pickWeightedWith(rSpacecraft, [
["Vessel", 549],
["Cruiser", 201],
["Scout", 138],
["Undocumented", 113],
])
const result = {
archetype: archetype,
displayedArchetype: displayedArchetype,
house: house,
role: role,
edgeStyle: edgeStyle,
iris: eyeColor,
paletteName,
_basePalette,
sight: eyes,
voice: mouth,
scent: nose,
ornament: ornament,
hollows: hollows,
realm: realm,
orientation: orientation,
count: count,
doctrine: "Idle", // fresh-mint default; derived from kill counts in applyMutations()
spacecraft: spacecraft,
aura: orbitingElements,
orbitalSpeed: pickWeightedWith(rOrbitSpeed, [
["Slow", 1],
["Medium", 1],
["Fast", 1],
]),
originTopology: pickWeightedWith(rOrigin, [
["Clustered Burst", 40],
["Dual-Core", 22],
["Radial Drift", 16],
["Tidal Sweep", 12],
["Void Edge", 8],
["Singular", 2]
]),
growthProfile: pickWeightedWith(rGrowth, [
["Bilateral", 45],
["Aniso-X", 18],
["Aniso-Y", 18],
["Eased-Then-Coast", 14],
["Breathing", 5]
]),
blendSchema: pickWeightedWith(rBlend, [
["NoBlend", 50],
["MostlyDIFF", 28],
["AltDiffAdd", 9],
["PowerTrio", 9],
["Nocturne", 3],
["InvertedNight", 1]
]),
sizeEnvelope: pickWeightedWith(rSize, [
["Compact", 33],
["Medium", 66],
["Titanic", 33]
]),
gridDensity: 92,
symbolSize: pickWeightedWith(rSymbolSize, [
[1.1, 70], // standard — dominant
[0.95, 15], // tight glyphs — rare
[1.3, 15], // bold glyphs — rare
]),
spawnRhythm: pickWeightedWith(rSpawn, [
["Clockwork", 45],
["Poisson", 35],
["BurstRest", 20]
]),
fillVariant: fillVariant,
wavyDirection: pickWeightedWith(rWavyDir, [
["horizontal", 40],
["vertical", 40],
["both", 20],
]),
expandingRotation: pickWeightedWith(rExpandRotation, [
["rotating", 20],
["static", 80],
]),
expandingPersist: pickWeightedWith(rExpandPersist, [
["fade", 20],
["persist", 80],
]),
forceMonochrome: monochromeList.includes(paletteName),
applyNoise: rApplyNoise() < 3006477107, // 70% chance noise is applied (0.7 * 2^32)
}
// ─── APPLY POST-MINT MUTATIONS ───
// After hash-derived traits are built, overlay any mutations from the
// kill-mechanic state. Mutations may modify visual traits (palette, iris, etc.)
// and bump counters (slain, remembering). For fresh-mint state, mutations are
// all zero/null and this is effectively a no-op.
const mutations = getMutations()
applyMutations(result, mutations)
// ─── ART BLOCKS FEATURES ───
// Public traits exposed to Art Blocks for on-chain metadata. Art Blocks
// reads window.$features after the render completes and writes each entry
// as a marketplace trait. Keep this list curated — only collector-facing traits.
// All other entries on `result` are render-only.
if (typeof window !== "undefined") {
window.$features = {
"Archetype": result.displayedArchetype,
"House": result.house,
"Class": result.role,
"Energy": ({
ExpandingCircles: "Pulse",
ExpandingSquares: "Lattice",
VerticalStripes: "Beam",
Gradient: "Bleed",
HorizontalStripes: "Strata",
WavyLines: "Frequency",
Sparkles: "Spark",
VoronoiCells: "Plasma",
}[result.fillVariant]) || result.fillVariant,
"Sight": result.sight,
"Iris": result.iris,
"Voice": result.voice,
"Scent": result.scent,
"Ornament": result.ornament,
"Aura": ({ CorruptMask: "Broken", CorruptRealm: "Fragments" }[result.aura]) || result.aura,
"Palette": result.paletteName,
"Realm": result.realm,
"Doctrine": result.doctrine,
"Spacecraft": result.spacecraft,
"Slain": result.slain.toString(),
"Remembering": result.remembering.toString(),
}
// If embedded in a parent (e.g. the kill tester), post features up.
// No-op when not embedded (window.parent === window).
if (window.parent && window.parent !== window) {
try {
window.parent.postMessage({ type: "satari-features", features: window.$features }, "*")
} catch (e) { /* ignore */ }
}
// Persist the public traits into a hidden #art-traits element so they survive in a
// captured HTML snapshot (Cloudflare Browser Rendering). Idempotent — also covers
// resetAndRegenerate, which rebuilds traits.
if (window.$art) window.$art.setTraits(window.$features)
}
return result
}
// Returns the rendering palette name — looks through Etched override.
// Use this anywhere render logic needs to dispatch on the underlying palette
// (Wraith voids, Crimson-specific archetype tweaks, etc).
function effectivePaletteName() {
return (TRAITS && (TRAITS._basePalette || TRAITS.paletteName)) || null
}
function resolveParams(T) {
const palettesByName = {
"Flux": ["#110033", "#00FFFF", "#39FF14", "#FFFF00", "#FF4500"],
"Acid": ["#EEFF00", "#FF00AA", "#00FFD5", "#CCFF00", "#FF6B00"],
"Surge": ["#00F0FF", "#00FFA3", "#00FFEF", "#23F0C7", "#00D4FF"],
"Infra": ["#FF0055", "#FF1493", "#FF6B00", "#110033", "#FF4D7E"],
"Toxin": ["#39FF14", "#00FF41", "#00FF85", "#B6FF00", "#110033"],
"Auric": ["#FFD700", "#FF00F5", "#FFA500", "#FF1493", "#110033"],
"Jazz": ["#00CED1", "#FF1493", "#7B68EE", "#00FFFF", "#FF69B4"],
"Corrupt": ["#00FF41", "#FF00F5", "#00FFFF", "#39FF14", "#FF0090"],
"Vapor": ["#FF6EC7", "#01CDFE", "#B967FF", "#05FFA1", "#110033"],
"Tropic": ["#FFD700", "#FF8C42", "#FFA07A", "#FF1493", "#110033"],
"Wraith": ["#FFFFFF", "#F5F5F5", "#EBEBEB", "#E0E0E0", "#D6D6D6"],
"Stark": ["#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF"],
"Riptide": ["#004466", "#003366", "#115588", "#2277AA", "#44AAFF"],
"Petal": ["#330011", "#551133", "#882255", "#BB4477", "#FF66AA"],
"Sage": ["#114422", "#110033", "#228855", "#44BB77", "#66FFAA"],
"Lilac": ["#2D1B4E", "#221155", "#442288", "#6644BB", "#9966FF"],
"Crimson": ["#330000", "#660000", "#990000", "#CC0000", "#FF0000"],
"Abyss": ["#001a33", "#003366", "#004d99", "#0066CC", "#0080FF"],
"Canopy": ["#0d2b0d", "#1a4d1a", "#2d7a2d", "#3fa03f", "#52cc52"],
"Amber": ["#1a0a00", "#ff6600", "#ffaa00", "#ffdd00", "#ffe566"],
"Haze": ["#1a0033", "#330066", "#4d0099", "#6600cc", "#8000ff"],
"Current": ["#0d0d33", "#1a1a66", "#2626cc", "#3333ff", "#6666ff"],
"Flare": ["#FF0000", "#FF4500", "#FF8C00", "#FFD700", "#FFFF00"],
"Coast": ["#00FFFF", "#00BFFF", "#0080FF", "#4040FF", "#8000FF"],
"Flora": ["#9ACD32", "#00FF7F", "#00FA9A", "#00CED1", "#00BFFF"],
"Astra": ["#4169E1", "#8A2BE2", "#9400D3", "#BA55D3", "#DA70D6"],
"Dune": ["#FF4500", "#FF6347", "#FF7F50", "#FFA500", "#FFB347"],
"Polar": ["#00CED1", "#20B2AA", "#48D1CC", "#7FFFD4", "#AFEEEE"],
"Clash": ["#FF4500", "#00FFFF", "#FF6347"],
"Royal": ["#8000FF", "#FFD700"],
"Axis": ["#00FF7F", "#FF00FF", "#00FA9A"],
"Blood": ["#8B0000", "#00CED1"],
"Signal": ["#FF00FF", "#00FF00", "#FF0080"],
"Dual": ["#FF1493", "#00FF88"],
}
let background = T.realm
// Etched preserves the underlying palette behavior — check _basePalette first.
const effectivePalette = T._basePalette || T.paletteName
if (effectivePalette === "Wraith") background = "Void"
if (effectivePalette === "Stark") background = "Void"
const isPureBW = effectivePalette === "Wraith" || effectivePalette === "Stark"
const canvasSize = width
const scaleFactor = 750 / 700 // Fixed to design-width ratio — grid density is visual, not pixel-dependent
const adjustedDensity = T.gridDensity / scaleFactor
let maxSupernovas = 20
let rectRange = [1, 1]
let sizeRange = {
Compact: [100 * _designScale, 520 * _designScale],
Medium: [300 * _designScale, 570 * _designScale],
Titanic: [400 * _designScale, 640 * _designScale]
}[T.sizeEnvelope]
const baseSpawnEvery = 12
const axisLock = T.orientation === "Axis-locked"
const symbolRNG = makeTraitRNG("symbolOffset")
const symbolOffset = floor(symbolRNG() * 52 / 4294967296)
const vocabRNG = makeTraitRNG("symbolVocab")
// Production weights — full vocabulary dominates, restricted vocabs are rare moments.
const vocabSize = pickWeightedWith(vocabRNG, [
[52, 90], // Full vocabulary — all 52 symbols (~90%)
[10, 5], // Restricted — 10 contiguous symbols (~5%)
[5, 3], // Tight — 5 contiguous symbols (~3%)
[2, 1], // Binary — 2 alternating symbols (~1%)
[1, 1], // Monosymbol — single symbol throughout (~1%)
])
return {
palette: palettesByName[effectivePalette],
background,
axisLock,
isPureBW,
maxSupernovas,
rectRange,
sizeRange,
gridDensity: adjustedDensity,
symbolSize: T.symbolSize,
spawnMode: T.spawnRhythm,
baseSpawnEvery,
fillVariant: T.fillVariant,
aura: T.aura,
orbitalSpeed: T.orbitalSpeed,
symbolOffset,
vocabSize,
}
}
function isTwoPartArchetype(archetypeName) {
const twoPartArchetypes = [
"Blade",
"Havoc",
"Vanta"
]
return twoPartArchetypes.includes(archetypeName)
}
class OrbitingElement {
constructor(type, centerX, centerY, palette, sharedTilt, speedSetting) {
this.type = type
this.centerX = centerX
this.centerY = centerY
this.palette = palette
this.angle = random(TWO_PI)
this.radiusX = random(300, 380) * _designScale
this.radiusY = random(120, 180) * _designScale
let speedRange
if (speedSetting === "Slow") {
speedRange = [0.0125, 0.03]
} else if (speedSetting === "Fast") {
speedRange = [0.0375, 0.075]
} else {
speedRange = [0.025, 0.05]
}
this.speed = random(speedRange[0], speedRange[1])
this.tilt = sharedTilt
this.size = random(12, 35) * _designScale
this.color = random(palette)
this.rotation = random(TWO_PI)
this.rotSpeed = random(-0.05, 0.05)
this.trailLength = random() < 0.7 ? 0 : floor(random(2, 5))
this.trail = []
}
update() {
if (this.trailLength > 0) {
this.trail.push({
angle: this.angle,
rotation: this.rotation
})
while (this.trail.length > this.trailLength) {
this.trail.shift()
}
}
this.angle += this.speed
this.rotation += this.rotSpeed
}
getPosition(angle = this.angle) {
const x = this.centerX + cos(angle) * this.radiusX
const y = this.centerY + sin(angle) * this.radiusY * cos(this.tilt)
const z = sin(angle) * this.radiusY * sin(this.tilt)
return { x, y, z }
}
display(g, behind) {
const pos = this.getPosition()
const isMainBehind = pos.z < 0
if (behind !== isMainBehind) return
if (this.trailLength > 0 && this.trail.length > 0) {
for (let i = 0; i < this.trail.length; i++) {
const trailPos = this.getPosition(this.trail[i].angle)
const scale = map(abs(trailPos.z), 0, this.radiusY, 1.0, 0.6)
const trailFade = map(i, 0, this.trail.length - 1, 0.2, 0.8)
const opacity = map(abs(trailPos.z), 0, this.radiusY, 255, 100) * trailFade
g.push()
g.translate(trailPos.x, trailPos.y)
g.rotate(this.trail[i].rotation)
this.drawShapeWithOpacity(g, scale * (0.5 + trailFade * 0.5), opacity)
g.pop()
}
}
const scale = map(abs(pos.z), 0, this.radiusY, 1.0, 0.6)
const opacity = map(abs(pos.z), 0, this.radiusY, 255, 100)
g.push()
g.translate(pos.x, pos.y)
g.rotate(this.rotation)
this.drawShapeWithOpacity(g, scale, opacity)
g.pop()
}
drawShapeWithOpacity(g, scale, opacity) {
const c = g.color(this.color)
c.setAlpha(opacity)
g.stroke(0, 0, 0)
g.strokeWeight(3 * scale * _designScale)
g.fill(c)
if (this.type === "Seeds") {
g.circle(0, 0, this.size * 2 * scale)
} else if (this.type === "Prisms") {
const s = this.size * scale
g.rectMode(CENTER)
g.rect(0, 0, s * 2, s * 2)
} else if (this.type === "Motes") {
g.circle(0, 0, this.size * scale)
}
}
}
function initOrbitingElements() {
orbitingElements = []
starburstRotation = 0
if (PARAMS.aura === "Calm") {
// no-op
} else if (PARAMS.aura === "Radiant") {
const starburstRNG = makeTraitRNG("starburstInit")
starburstRotation = starburstRNG() / 4294967296 * TWO_PI
} else if (PARAMS.aura === "Skyfall") {
initSkyfall()
} else if (PARAMS.aura === "Melt") {
initMelt()
} else if (PARAMS.aura === "CorruptMask" || PARAMS.aura === "CorruptRealm") {
initCorrupt()
} else if (PARAMS.aura === "Vent") {
initVent()
} else if (PARAMS.aura === "Kintsugi") {
initCrack()
} else if (PARAMS.aura === "Seeds" || PARAMS.aura === "Motes" || PARAMS.aura === "Prisms") {
// Random orbital elements of the given type (count 3-19).
const cx = width / 2
const cy = headCenterY()
const tiltRNG = makeTraitRNG("orbitTilt")
const sharedTilt = (tiltRNG() / 4294967296) * 1.6 - 0.8
const countRNG = makeTraitRNG("orbitCount")
const count = 3 + Math.floor((countRNG() / 4294967296) * 17) // 3-19
for (let i = 0; i < count; i++) {
orbitingElements.push(
new OrbitingElement(PARAMS.aura, cx, cy, PARAMS.palette, sharedTilt, PARAMS.orbitalSpeed)
)
}
}
}
function drawStarburst(g) {
g.push()
g.translate(width / 2, headCenterY()) // v2: center on head zone
g.rotate(starburstRotation)
const _starburstRNG = makeTraitRNG("starburst")
const starburstRNG = () => _starburstRNG() / 4294967296
const rayCount = floor(starburstRNG() * 16 + 24)
const innerRadius = min(width, height) * 0.15
const outerRadius = max(width, height) * 1.65
const rayThickness = (starburstRNG() * 50 + 18) * _designScale
const colorIndex = floor(starburstRNG() * PARAMS.palette.length)
const starburstColor = PARAMS.palette[colorIndex]
g.fill(starburstColor)
g.stroke(0, 0, 0)
g.strokeWeight(5 * _designScale)
for (let i = 0; i < rayCount; i++) {
const angle = (TWO_PI / rayCount) * i
const x1 = cos(angle) * innerRadius
const y1 = sin(angle) * innerRadius
const x2 = cos(angle) * outerRadius
const y2 = sin(angle) * outerRadius
const lengthVariation = starburstRNG() * 0.15 + 0.85
const tipX = x2 * lengthVariation
const tipY = y2 * lengthVariation
const perpAngle = angle + HALF_PI
const baseHalfWidth = rayThickness / 2
g.beginShape()
g.vertex(x1 + cos(perpAngle) * baseHalfWidth, y1 + sin(perpAngle) * baseHalfWidth)
g.vertex(tipX, tipY)
g.vertex(x1 - cos(perpAngle) * baseHalfWidth, y1 - sin(perpAngle) * baseHalfWidth)
g.endShape(CLOSE)
}
g.pop()
}
class SkyfallParticle {
constructor(palette, scatter = false) {
this.reset(palette, scatter)
}
reset(palette, scatter = false) {
this.x = random(width)
this.y = scatter ? random(-height * 0.2, height) : random(-height * 0.2, -50 * _designScale)
this.speed = random(5, 10) * _designScale
this.length = random(50, 300) * _designScale
this.thickness = random(2, 5) * _designScale
this.color = random(palette)
this.opacity = scatter ? random(50, 255) : random(150, 255)
this.fadeSpeed = random(1.25, 5)
}
update() {
this.y += this.speed
this.opacity -= this.fadeSpeed
if (this.y > height + 100 || this.opacity <= 0) {
this.reset(PARAMS.palette)
}
}
display(g) {
if (this.opacity <= 0) return
const c = g.color(this.color)
c.setAlpha(this.opacity)
g.push()
g.stroke(c)
g.strokeWeight(this.thickness)
g.line(this.x, this.y, this.x, this.y + this.length)
g.noStroke()
g.fill(c)
g.circle(this.x, this.y, this.thickness * 3)
g.pop()
}
}
let skyfallParticles = []
function initSkyfall() {
skyfallParticles = []
if (PARAMS.aura === "Skyfall") {
const count = floor(random(15, 80))
for (let i = 0; i < count; i++) {
skyfallParticles.push(new SkyfallParticle(PARAMS.palette, true))
}
}
}
function drawSkyfall(g) {
if (PARAMS.aura === "Skyfall") {
for (let p of skyfallParticles) {
p.display(g)
}
}
}
function updateSkyfall() {
if (PARAMS.aura === "Skyfall") {
for (let p of skyfallParticles) {
p.update()
}
}
}
// ──────────────────────────────────────────────────────────────
// VENT — rising particles from bottom, thicker than Skyfall, meandering ascent
// Particles spawn at random x-positions at the bottom, drift upward with a sine
// meander, fade out in the top third. Hard alpha (no additive blend).
// ──────────────────────────────────────────────────────────────
class VentParticle {
constructor(palette, scatter = false) {
this.reset(palette, scatter)
}
reset(palette, scatter = false) {
this.x = random(width)
this.baseX = this.x // track origin for sine meander
this.y = scatter ? random(0, height) : random(height, height + 100)
this.speed = random(1.5, 3.5) * _designScale // slower than Skyfall (rises, not falls fast)
this.size = random(4, 10) * _designScale // thicker than Skyfall (Skyfall is 2-5)
this.color = random(palette)
this.opacity = 220 + random(35) // hard alpha — opaque
this.meanderAmp = random(8, 22) * _designScale
this.meanderFreq = random(0.012, 0.028)
this.meanderPhase = random(TWO_PI)
this.life = 0 // tracks time alive for meander progression
}
update() {
this.life += 1
this.y -= this.speed
// Horizontal meander as a sine of life-time
this.x = this.baseX + sin(this.life * this.meanderFreq + this.meanderPhase) * this.meanderAmp
// Fade out gently in the top third
if (this.y < height * 0.33) {
this.opacity -= 2.5
}
if (this.y < -50 || this.opacity <= 0) {
this.reset(PARAMS.palette)
}
}
display(g) {
if (this.opacity <= 0) return
const c = g.color(this.color)
c.setAlpha(this.opacity)
g.push()
g.noStroke()
g.fill(c)
g.circle(this.x, this.y, this.size)
g.pop()
}
}
let ventParticles = []
function initVent() {
ventParticles = []
if (PARAMS.aura === "Vent") {
const count = 70 // denser than Skyfall (Skyfall picks 15-80, Vent fixed 70)
for (let i = 0; i < count; i++) {
ventParticles.push(new VentParticle(PARAMS.palette, true))
}
}
}
function drawVent(g) {
if (PARAMS.aura === "Vent") {
for (let p of ventParticles) p.display(g)
}
}
function updateVent() {
if (PARAMS.aura === "Vent") {
for (let p of ventParticles) p.update()
}
}
// ──────────────────────────────────────────────────────────────
// KINTSUGI — 1-3 jagged fissures across canvas, behind form layer.
// Each crack is a polyline path. Color rotates through palette at 4x Omnia speed
// (Omnia = 0.08, so Kintsugi = 0.32). All cracks share the same palette but each
// has its own phase offset so they don't all flash the same color simultaneously.
// ──────────────────────────────────────────────────────────────
let crackPaths = []
function initCrack() {
crackPaths = []
if (PARAMS.aura !== "Kintsugi") return
const _crackRNG = makeTraitRNG("crackInit")
const crackRNG = () => _crackRNG() / 4294967296
const crackCount = 4 + floor(crackRNG() * 5) // 4-8 cracks
for (let i = 0; i < crackCount; i++) {
const path = []
// Each crack travels from one edge to another. Pick start and end edges.
// 4 edges: 0=top, 1=right, 2=bottom, 3=left
const startEdge = floor(crackRNG() * 4)
let endEdge = floor(crackRNG() * 4)
while (endEdge === startEdge) endEdge = floor(crackRNG() * 4)
const edgeToPoint = (edge, t) => {
if (edge === 0) return { x: t * width, y: 0 }
if (edge === 1) return { x: width, y: t * height }
if (edge === 2) return { x: t * width, y: height }
return { x: 0, y: t * height }
}
const start = edgeToPoint(startEdge, crackRNG())
const end = edgeToPoint(endEdge, crackRNG())
// 5-9 jagged segments between start and end
const segments = 5 + floor(crackRNG() * 5)
const jitterMag = min(width, height) * 0.08
path.push(start)
for (let s = 1; s < segments; s++) {
const t = s / segments
const baseX = lerp(start.x, end.x, t)
const baseY = lerp(start.y, end.y, t)
// Perpendicular jitter
const dx = end.x - start.x
const dy = end.y - start.y
const len = sqrt(dx * dx + dy * dy) || 1
const perpX = -dy / len
const perpY = dx / len
const jitter = (crackRNG() * 2 - 1) * jitterMag
path.push({ x: baseX + perpX * jitter, y: baseY + perpY * jitter })
}
path.push(end)
crackPaths.push({
points: path,
phaseOffset: crackRNG() * TWO_PI, // each crack at different palette phase
thickness: (2 + crackRNG() * 3) * _designScale,
})
}
}
function getCrackColor(phaseOffset) {
// 4x Omnia speed (Omnia = 0.08, Kintsugi = 0.32). Uses palette colors directly
// (not complementary like Omnia).
const cycleSpeed = 0.32
const palette = PARAMS.palette
const cyclePos = (millis() * cycleSpeed + phaseOffset * 100) % (palette.length * 100)
const idx = floor(cyclePos / 100) % palette.length
const next = (idx + 1) % palette.length
const blend = (cyclePos % 100) / 100
const c1 = color(palette[idx])
const c2 = color(palette[next])
return lerpColor(c1, c2, blend)
}
function drawCrack(g) {
if (PARAMS.aura !== "Kintsugi") return
for (const crack of crackPaths) {
const col = getCrackColor(crack.phaseOffset)
g.push()
g.stroke(col)
g.strokeWeight(crack.thickness)
g.noFill()
g.beginShape()
for (const p of crack.points) {
g.vertex(p.x, p.y)
}
g.endShape()
g.pop()
}
}
let meltStreams = []
function initMeltStreams() {
meltStreams = []
const numStreams = floor(random(2, 5))
const totalWidth = random(50, 200) * _designScale
for (let i = 0; i < numStreams; i++) {
const streamWidth = totalWidth / numStreams * random(0.5, 1.5)
meltStreams.push({
centerX: width / 2 + random(-totalWidth / 2, totalWidth / 2),
width: streamWidth
})
}
}
class MeltParticle {
constructor(palette, scatter = false) {
this.assignStream()
this.reset(palette, scatter)
}
assignStream() {
if (meltStreams.length > 0) {
this.stream = random(meltStreams)
} else {
this.stream = { centerX: width / 2, width: 100 * _designScale }
}
}
reset(palette, scatter = false) {
this.assignStream()
this.x = this.stream.centerX + random(-this.stream.width / 2, this.stream.width / 2)
const startY = globalBounds ? globalBounds.centerY : height / 2
this.y = scatter ? random(startY, height * 1.2) : startY + random(-20, 20) * _designScale
this.speed = random(3, 8) * _designScale
this.targetLength = random(30, 200) * _designScale
this.targetThickness = random(1, 12) * _designScale
this.currentLength = scatter ? random(0, this.targetLength) : 0
this.currentThickness = scatter ? random(0, this.targetThickness) : 0
this.growthRate = random(2, 6) * _designScale
this.color = random(palette)
this.opacity = scatter ? random(80, 255) : random(180, 255)
this.fadeSpeed = random(0.8, 3)
this.startDelay = scatter ? 0 : floor(random(0, 60))
this.waiting = !scatter
this.maxTravelY = startY + random(150 * _designScale, height * 0.8)
}
update() {
if (this.waiting) {
this.startDelay--
if (this.startDelay <= 0) {
this.waiting = false
}
return
}
this.y += this.speed
this.opacity -= this.fadeSpeed
this.currentLength = min(this.currentLength + this.growthRate, this.targetLength)
this.currentThickness = min(this.currentThickness + this.growthRate * 0.1, this.targetThickness)
if (this.y > this.maxTravelY - 100 * _designScale) {
this.opacity -= this.fadeSpeed * 2
}
if (this.y > this.maxTravelY || this.opacity <= 0) {
this.reset(PARAMS.palette)
}
}
display(g) {
if (this.waiting || this.opacity <= 0 || this.currentThickness < 0.1) return
const c = g.color(this.color)
c.setAlpha(this.opacity)
g.push()
g.stroke(c)
g.strokeWeight(this.currentThickness)
g.line(this.x, this.y, this.x, this.y + this.currentLength)
g.noStroke()
g.fill(c)
g.circle(this.x, this.y, this.currentThickness * 2.5)
g.pop()
}
}
let meltParticles = []
let meltDelayFrames = 50
let meltFrameCount = 0
function initMelt() {
meltParticles = []
meltFrameCount = 0
if (PARAMS.aura === "Melt") {
initMeltStreams()
const count = floor(random(30, 90))
for (let i = 0; i < count; i++) {
meltParticles.push(new MeltParticle(PARAMS.palette, false))
}
}
}
function drawMelt(g) {
if (PARAMS.aura === "Melt" && meltFrameCount >= meltDelayFrames) {
for (let p of meltParticles) {
p.display(g)
}
}
}
function updateMelt() {
if (PARAMS.aura === "Melt") {
meltFrameCount++
if (meltFrameCount >= meltDelayFrames) {
for (let p of meltParticles) {
p.update()
}
}
}
}
class CorruptSlice {
constructor(forMask = false) {
this.forMask = forMask
this.reset()
}
reset() {
this.y = random(height)
this.sliceHeight = this.forMask ? random(2, 15) * _designScale : random(5, 60) * _designScale
this.sliceWidth = random(80, 250) * _designScale
this.sliceX = width / 2 - this.sliceWidth / 2 + random(-30, 30) * _designScale
this.offset = random(-80, 80) * _designScale
this.duration = floor(random(2, 8))
this.age = 0
this.active = false
}
trigger() {
this.reset()
this.active = true
}
update() {
if (this.active) {
this.age++
if (this.age >= this.duration) {
this.active = false
}
}
}
}
let corruptSlices = []
let corruptTimer = 0
let corruptBurstMode = false
let corruptBurstCount = 0
function initCorrupt() {
corruptSlices = []
corruptTimer = 0
corruptBurstMode = false
corruptBurstCount = 0
const isMask = PARAMS.aura === "CorruptMask"
const poolSize = isMask ? 40 : 15
for (let i = 0; i < poolSize; i++) {
corruptSlices.push(new CorruptSlice(isMask))
}
}
function updateCorrupt() {
if (PARAMS.aura !== "CorruptMask" && PARAMS.aura !== "CorruptRealm") return
const isMask = PARAMS.aura === "CorruptMask"
corruptTimer++
for (let slice of corruptSlices) {
slice.update()
}
if (corruptBurstMode) {
corruptBurstCount--
if (corruptBurstCount <= 0) {
corruptBurstMode = false
} else if (random() < 0.7) {
triggerRandomSlice()
}
}
const triggerChance = isMask ? 0.15 : 0.02
const burstChance = isMask ? 0.5 : 0.3
if (!corruptBurstMode && random() < triggerChance) {
if (random() < burstChance) {
corruptBurstMode = true
corruptBurstCount = floor(random(isMask ? 10 : 5, isMask ? 30 : 20))
} else {
triggerRandomSlice()
}
}
}
function triggerRandomSlice() {
for (let slice of corruptSlices) {
if (!slice.active) {
slice.trigger()
break
}
}
}
class FillAnimation {
constructor(x, y, palette, traits) {
this.x = x
this.y = y
this.palette = palette
this.traits = traits
this.age = 0
this.fadeInDuration = 24 // ~1 second at 24fps
}
update() {
this.age++
}
reset(x, y, palette, traits) {
this.x = x
this.y = y
this.palette = palette
this.traits = traits
this.age = 0
}
display(g) { }
getBlend(requestedMode) {
if (!this.traits || !this.traits.blendSchema) return BLEND
const schema = this.traits.blendSchema
if (schema === "NoBlend") return BLEND
if (schema === "MostlyDIFF") return DIFFERENCE
if (schema === "AltDiffAdd") return requestedMode
if (schema === "PowerTrio") return requestedMode
if (schema === "Nocturne") {
if (requestedMode === ADD || requestedMode === SCREEN) return MULTIPLY
return requestedMode
}
if (schema === "InvertedNight") {
if (requestedMode === MULTIPLY) return SCREEN
return requestedMode
}
return BLEND
}
isDone() {
return false
}
averageOpacity() {
return 100
}
}
class Supernova extends FillAnimation {
constructor(x, y, palette, count, sizeRange, traits) {
super(x, y, palette, traits)
this.count = count
this.sizeRange = sizeRange
this.rectangles = []
this.startedCount = 0
this.fadedCount = 0
this.shouldRotate = random() > 0.8
const layers = Math.ceil(count / 8)
const rectsPerLayer = Math.ceil(count / layers)
let rectIndex = 0
for (let layer = 0; layer < layers && rectIndex < count; layer++) {
const numInLayer = Math.min(rectsPerLayer, count - rectIndex)
for (let i = 0; i < numInLayer; i++) {
const angle = (TWO_PI / numInLayer) * i + random(-0.2, 0.2)
const speed = (this.shouldRotate ? random(0.005, 0.015) : random(0.015, 0.025)) * FILL_ANIMATION_SPEED_MULT
const startDist = random(10, 40)
const sx = this.x + cos(angle) * startDist
const sy = this.y + sin(angle) * startDist
const layerSizeMod = layers > 1 ? map(layer, 0, layers - 1, 1.2, 0.8) : 1.0
const w = random(sizeRange[0], sizeRange[1]) * layerSizeMod
const h = random(sizeRange[0], sizeRange[1]) * layerSizeMod
const col = random(palette)
const fadeRate = random(1.0, 2.0)
const blendMode = this.pickBlend(rectIndex)
this.rectangles.push({
x: sx,
y: sy,
vx: cos(angle) * speed,
vy: sin(angle) * speed,
width: 0,
height: 0,
baseWidth: w,
baseHeight: h,
color: col,
opacity: 100,
fadeRate,
blendMode,
rotation: this.shouldRotate ? angle + HALF_PI : 0,
rotSpeed: this.shouldRotate ? random(-0.005, 0.005) : 0,
growthPhase: random(TWO_PI),
layer: layer,
angle: angle,
delay: rectIndex * 6,
started: false,
faded: false,
maxLife: floor(random(80, 400)),
selfFading: false
})
rectIndex++
}
}
}
pickBlend(i) {
return this.getBlend(i % 2 === 0 ? DIFFERENCE : ADD)
}
reset(x, y, palette, count, sizeRange, traits) {
super.reset(x, y, palette, traits)
this.count = count
this.sizeRange = sizeRange
this.rectangles = []
this.startedCount = 0
this.fadedCount = 0
this.shouldRotate = random() > 0.8 // 20% chance, matching constructor
const layers = Math.ceil(count / 8)
const rectsPerLayer = Math.ceil(count / layers)
let rectIndex = 0
for (let layer = 0; layer < layers && rectIndex < count; layer++) {
const numInLayer = Math.min(rectsPerLayer, count - rectIndex)
for (let i = 0; i < numInLayer; i++) {
const angle = (TWO_PI / numInLayer) * i + random(-0.2, 0.2)
const speed = (this.shouldRotate ? random(0.015, 0.045) : random(0.025, 0.075)) * FILL_ANIMATION_SPEED_MULT
const startDist = random(1, 4)
const sx = this.x + cos(angle) * startDist
const sy = this.y + sin(angle) * startDist
const layerSizeMod = layers > 1 ? map(layer, 0, layers - 1, 1.2, 0.8) : 1.0
const w = random(sizeRange[0], sizeRange[1]) * layerSizeMod
const h = random(sizeRange[0], sizeRange[1]) * layerSizeMod
const col = random(palette)
const fadeRate = random(1.0, 2.0)
const blendMode = this.pickBlend(rectIndex)
this.rectangles.push({
x: sx,
y: sy,
vx: cos(angle) * speed,
vy: sin(angle) * speed,
width: 0,
height: 0,
baseWidth: w,
baseHeight: h,
color: col,
opacity: 100,
fadeRate,
blendMode,
rotation: this.shouldRotate ? angle + HALF_PI : 0,
rotSpeed: this.shouldRotate ? random(-0.005, 0.005) : 0,
growthPhase: random(TWO_PI),
layer: layer,
angle: angle,
delay: rectIndex * 6,
started: false,
faded: false,
maxLife: floor(random(80, 400)),
selfFading: false
})
rectIndex++
}
}
}
update() {
super.update()
for (let r of this.rectangles) {
if (this.age < r.delay) continue
if (!r.started) {
r.started = true
r.startAge = this.age
this.startedCount++
}
let newX = r.x + r.vx
let newY = r.y + r.vy
const constrained = constrainToMaskBounds(newX, newY, r.vx, r.vy)
r.x = constrained.x
r.y = constrained.y
r.vx = constrained.vx
r.vy = constrained.vy
r.rotation += r.rotSpeed
const rectAge = r.started ? (this.age - r.startAge) : 0
if (!r.selfFading && rectAge > r.maxLife) {
r.selfFading = true
}
if (r.selfFading) {
r.opacity = max(0, r.opacity - r.fadeRate)
}
if (!r.faded && r.opacity <= 0) {
r.faded = true
this.fadedCount++
}
this.applyGrowthProfile(r)
}
}
applyGrowthProfile(r) {
const profile = this.traits.growthProfile
const rectAge = r.started ? (this.age - r.startAge) : 0
const expansionDuration = 90
let baseScale = 1.0
if (rectAge < expansionDuration) {
const t = rectAge / expansionDuration
baseScale = 1 - pow(1 - t, 3)
}
if (profile === "Bilateral") {
const scale = baseScale * (1.0 + sin(rectAge * 0.05 + r.growthPhase) * 0.15)
r.width = r.baseWidth * scale
r.height = r.baseHeight * scale
} else if (profile === "Aniso-X") {
const scaleX = baseScale * (1.0 + sin(rectAge * 0.05 + r.growthPhase) * 0.3)
const scaleY = baseScale * (1.0 + sin(rectAge * 0.05 + r.growthPhase) * 0.05)
r.width = r.baseWidth * scaleX
r.height = r.baseHeight * scaleY
} else if (profile === "Aniso-Y") {
const scaleX = baseScale * (1.0 + sin(rectAge * 0.05 + r.growthPhase) * 0.05)
const scaleY = baseScale * (1.0 + sin(rectAge * 0.05 + r.growthPhase) * 0.3)
r.width = r.baseWidth * scaleX
r.height = r.baseHeight * scaleY
} else if (profile === "Eased-Then-Coast") {
const easeDuration = 120
if (rectAge < easeDuration) {
const t = rectAge / easeDuration
const easeOut = 1 - pow(1 - t, 3)
const scale = baseScale * (1.0 + easeOut * 0.4)
r.width = r.baseWidth * scale
r.height = r.baseHeight * scale
} else {
r.width = r.baseWidth * 1.4
r.height = r.baseHeight * 1.4
}
} else if (profile === "Breathing") {
const breathFreq = 0.05
const breathDepth = 0.5
const breathPhase = sin(rectAge * breathFreq + r.growthPhase)
const scale = baseScale * (1.0 + breathPhase * breathDepth)
r.width = r.baseWidth * scale
r.height = r.baseHeight * scale
} else {
r.width = r.baseWidth * baseScale
r.height = r.baseHeight * baseScale
}
}
display(g) {
for (let r of this.rectangles) {
if (!r.started || r.opacity <= 0) continue
g.push()
g.blendMode(r.blendMode)
g.translate(r.x, r.y)
g.rotate(r.rotation)
const c = g.color(r.color)
c.setAlpha(r.opacity)
g.fill(c)
g.noStroke()
g.rectMode(CENTER)