index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MOFUMOFU</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--ink:#1d1d1f;--mute:#9a9aa2;--line:#ececf0;--pink:#ff8fb1}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);font:12px/1.4 Inter,system-ui,-apple-system,sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}
#c{position:fixed;inset:0;display:block;width:100%;height:100%;touch-action:none;outline:none}
#brand{position:fixed;left:24px;top:20px;user-select:none}
.logo{font-family:'M PLUS Rounded 1c',sans-serif;font-weight:800;letter-spacing:.22em;font-size:10px;color:var(--mute)}
#name{font-family:'M PLUS Rounded 1c',sans-serif;font-weight:800;font-size:32px;line-height:1.15;cursor:pointer;margin-top:2px}
#name:hover{opacity:.7}
#topbtns{display:flex;gap:6px;margin-top:12px}
button{font:inherit;font-weight:500;border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 15px;cursor:pointer;color:var(--ink);transition:background .15s,border-color .15s}
button:hover{border-color:#cfcfd6}
button.primary{background:var(--ink);color:#fff;border-color:var(--ink)}
button.primary:hover{background:#3a3a3e}
button.primary.pending::after{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--pink);margin-left:8px;vertical-align:1px}
#bottom{position:fixed;left:24px;bottom:20px;max-width:440px}
#hint{color:var(--mute);font-size:11px;line-height:1.5;margin-bottom:10px}
#files{display:flex;gap:6px}
#files button{padding:6px 12px;font-size:11px}
#panel{position:fixed;top:16px;right:16px;bottom:16px;width:300px;overflow:auto;background:rgba(255,255,255,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--line);border-radius:16px;padding:4px 16px 16px;scrollbar-width:thin;scrollbar-color:#e2e2e6 transparent}
#panel.hidden{display:none}
details{border-bottom:1px solid var(--line)}
details:last-child{border-bottom:0}
summary{cursor:pointer;list-style:none;padding:12px 0;font-weight:600;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;display:flex;justify-content:space-between;user-select:none}
summary::-webkit-details-marker{display:none}
summary::after{content:'+';color:var(--mute);font-weight:400;font-size:13px;letter-spacing:0}
details[open] summary::after{content:'–'}
details[open]{padding-bottom:8px}
.row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:3px 8px;margin:6px 0 9px}
.row label{color:#4a4a50}
.row label.live::before{content:'';display:inline-block;width:5px;height:5px;border-radius:50%;background:#bfe8c8;margin-right:6px;vertical-align:1px}
.row .val{color:var(--mute);font-variant-numeric:tabular-nums;font-size:11px}
.row input[type=range]{grid-column:1/-1;width:100%;margin:0}
input[type=range]{-webkit-appearance:none;appearance:none;height:16px;background:transparent;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:#e4e4e9;border-radius:2px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--ink);margin-top:-5px;border:0}
input[type=range]::-moz-range-track{height:2px;background:#e4e4e9;border-radius:2px}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--ink)}
.chips{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:4px}
.chip{border:1px solid var(--line);background:#fff;border-radius:999px;padding:4px 10px;font-size:11px;cursor:pointer;color:#444}
.chip.on{background:var(--ink);color:#fff;border-color:var(--ink)}
input[type=color]{-webkit-appearance:none;appearance:none;width:36px;height:20px;border:1px solid var(--line);border-radius:7px;padding:0;background:none;cursor:pointer}
input[type=color]::-webkit-color-swatch-wrapper{padding:0}
input[type=color]::-webkit-color-swatch{border:none;border-radius:6px}
input[type=color]::-moz-color-swatch{border:none;border-radius:6px}
.sw{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
.sw span{width:22px;height:22px;border-radius:50%;cursor:pointer;border:1px solid var(--line);transition:transform .15s}
.sw span:hover{transform:scale(1.15)}
.tog{position:relative;width:30px;height:17px;border-radius:999px;background:#e4e4e9;cursor:pointer;border:0;padding:0}
.tog::after{content:'';position:absolute;left:2px;top:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
.tog.on{background:var(--ink)}
.tog.on::after{left:15px}
.legend{color:var(--mute);font-size:10.5px;padding:10px 0 2px;line-height:1.5}
#react{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);display:flex;gap:2px;background:rgba(255,255,255,.92);border:1px solid var(--line);border-radius:999px;padding:4px}
#react button{border:0;padding:7px 14px;background:transparent}
#react button:hover{background:#f3f3f6}
#react button.on{background:var(--ink);color:#fff}
@media (max-width:760px){#react{bottom:calc(46vh + 12px)}}
#togglePanel{position:fixed;right:16px;top:16px;display:none}
#loadInput{display:none}
@media (max-width:760px){
#panel{top:auto;left:0;right:0;bottom:0;width:auto;height:46vh;border-radius:16px 16px 0 0}
#togglePanel{display:block}
#bottom{display:none}
#name{font-size:24px}
}
</style>
</head>
<body>
<canvas id="c" tabindex="0"></canvas>
<div id="brand">
<div class="logo">MOFUMOFU · v0.6</div>
<div id="name" title="Neuer Name">Kinako</div>
<div id="topbtns">
<button id="bGen" class="primary">Generieren</button>
<button id="bRnd">Zufall</button>
</div>
</div>
<div id="bottom">
<div id="hint">Augen, Mund, Nase, Bäckchen, Arme und Füße direkt am Modell ziehen. Maus übers Fell = streicheln, Klick = attract. Tasten 1–5 lösen die Reaktionen aus.</div>
<div id="files"><button id="bPng">PNG</button><button id="bSave">JSON</button><button id="bLoad">Laden</button><input type="file" id="loadInput" accept=".json,application/json"></div>
</div>
<div id="react">
<button data-r="attract" title="1">attract</button>
<button data-r="repel" title="2">repel</button>
<button data-r="question" title="3">question</button>
<button data-r="idle" title="4">idle</button>
<button data-r="dream" title="5">dream</button>
</div>
<button id="togglePanel">Parameter</button>
<div id="panel"></div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
const V3 = THREE.Vector3;
const X = new V3(1,0,0), Y = new V3(0,1,0), Z = new V3(0,0,1);
const ZERO2 = new THREE.Vector2();
const D2R = Math.PI/180;
const R = (a,b)=>a+Math.random()*(b-a);
const pick = a=>a[Math.floor(Math.random()*a.length)];
const chance = p=>Math.random()<p;
const clamp = (v,a,b)=>Math.min(b,Math.max(a,v));
const smoothstep = (a,b,x)=>{ const t = clamp((x-a)/(b-a),0,1); return t*t*(3-2*t); };
function mulberry(seed){ let a = seed>>>0; return ()=>{ a = (a + 0x6D2B79F5)|0; let t = Math.imul(a ^ (a>>>15), 1|a); t = (t + Math.imul(t ^ (t>>>7), 61|t)) ^ t; return ((t ^ (t>>>14))>>>0)/4294967296; }; }
/* ================= PARAMETER ================= */
const DEF = {
seed:1234, shape:'mochi',
headSize:1.0, headW:1.18, headH:.92, headD:.98, pear:.3, square:.1, point:0, tri:0, lump:0, flat:.2, waist:0, hunch:0,
faceType:'smooth', faceShape:'oval', faceFit:true, faceW:.25, faceH:.25, faceY:0, faceGloss:.65, faceSoft:.5, faceBump:.35,
eyeType:'button', eyeShine:'one', eyeSize:.14, eyeX:20, eyeY:6, eyeDepth:.6, eyeGloss:.85, eyeLine:.35, eyeColor:'#22160f',
mouthType:'oval', mouthSize:.1, mouthW:1.7, mouthY:-14, noseType:'dot', noseY:-5,
blush:.6, blushSize:.17, blushX:33, blushY:-8,
armType:'stubby', armSize:.17, armLength:.2, armX:82, armY:-22, armAngle:18,
legType:'paws', legSize:.28, legLength:.04, legSpread:24, tailType:'pom', tailSize:.5,
furLength:.17, furWidth:.022, furDensity:3200, furSegs:6, furGravity:.75, furStiff:.5, furLean:.35, furCurl:.06, furCurlFreq:9, furVar:.45, furWind:.25, tuft:.5, bellyPatch:.28, furShag:.3, furSheen:.5, furTip:.3, bald:0, baldScale:1,
patType:'none', patScale:3, patAmt:.5,
colTop:'#f6e2cf', colBot:'#e9c8a8', colPat:'#c99a74', colPat2:'#efd6b8', colPatch:'#fff5ea', colFace:'#ffe6d8', colBlush:'#ff9b9b', colLips:'#e88a86',
breath:1, blink:true,
voiceWave:'square', voicePitch:.4, voiceSpeed:1, voiceRough:.45, voiceVib:.08, volume:.7
};
const P = structuredClone(DEF);
const PALETTES = [
{n:'Kinako', colTop:'#f6e2cf', colBot:'#e9c8a8', colPat:'#c99a74', colPat2:'#efd6b8', colPatch:'#fff5ea', colFace:'#ffe6d8', colBlush:'#ff9b9b', eyeColor:'#22160f'},
{n:'Sakura', colTop:'#ffd6e0', colBot:'#ffc0d1', colPat:'#f29bb5', colPat2:'#ffe6a8', colPatch:'#fff5f2', colFace:'#ffe7e1', colBlush:'#ff8fab', eyeColor:'#1d1418'},
{n:'Sora', colTop:'#d3e8ff', colBot:'#b2d3fb', colPat:'#86b2ee', colPat2:'#e2d4ff', colPatch:'#f6fbff', colFace:'#fff0ea', colBlush:'#ff9ec0', eyeColor:'#141a2a'},
{n:'Matcha', colTop:'#dcf2d6', colBot:'#bfe3c4', colPat:'#8fcb9b', colPat2:'#f3e3b0', colPatch:'#fbfff5', colFace:'#fff3e6', colBlush:'#ffadad', eyeColor:'#1a2018'},
{n:'Taro', colTop:'#e9dcff', colBot:'#d2bdfb', colPat:'#ad8ff0', colPat2:'#ffd1e8', colPatch:'#fff8fd', colFace:'#fff0f4', colBlush:'#ff99c8', eyeColor:'#1e1428'},
{n:'Purin', colTop:'#fff2c2', colBot:'#ffe39c', colPat:'#f0b765', colPat2:'#ffd0b5', colPatch:'#fffdf3', colFace:'#fff2e2', colBlush:'#ff9aa2', eyeColor:'#2a1c10'},
{n:'Mint', colTop:'#cff2ed', colBot:'#a9e4dc', colPat:'#72c8bd', colPat2:'#ffd6e5', colPatch:'#f6fffd', colFace:'#f3fffd', colBlush:'#ffafcc', eyeColor:'#13201e'},
{n:'Momo', colTop:'#ffdfd2', colBot:'#ffc6b6', colPat:'#f59a86', colPat2:'#ffe7b0', colPatch:'#fff6f1', colFace:'#ffeee6', colBlush:'#ff8a9e', eyeColor:'#2a1515'},
{n:'Shiro', colTop:'#f7f7f7', colBot:'#e9e8f0', colPat:'#c9c6d8', colPat2:'#f3d9e6', colPatch:'#ffffff', colFace:'#fff1f3', colBlush:'#ffb0c8', eyeColor:'#1b1b22'},
];
const SHAPES = {
mochi: {headW:1.22, headH:.86, headD:1.0, pear:.3, square:.12, point:0, tri:0, lump:0, flat:.3},
kugel: {headW:1.0, headH:1.0, headD:1.0, pear:0, square:0, point:0, tri:0, lump:0, flat:.1},
ei: {headW:.95, headH:1.15,headD:.95, pear:.35, square:0, point:0, tri:0, lump:0, flat:.12},
birne: {headW:1.0, headH:1.1, headD:.95, pear:.9, square:0, point:.1, tri:0, lump:0, flat:.15},
bohne: {headW:1.15, headH:.95, headD:.9, pear:.1, square:.05, point:0, tri:0, lump:.65,flat:.12},
onigiri:{headW:1.15, headH:1.0, headD:.85, pear:.1, square:.35, point:0, tri:.85, lump:0, flat:.25},
tropfen:{headW:1.0, headH:1.0, headD:.98, pear:.3, square:0, point:.85,tri:0, lump:0, flat:.15},
wuerfel:{headW:1.05, headH:.98, headD:.98, pear:0, square:.85, point:0, tri:0, lump:0, flat:.1},
fladen: {headW:1.65, headH:.55, headD:1.3, pear:.2, square:.2, point:0, tri:0, lump:0, flat:.4},
stange: {headW:.62, headH:1.75, headD:.65, pear:.15, square:.15, point:0, tri:0, lump:0, flat:.15},
sack: {headW:1.1, headH:1.15,headD:1.0, pear:1.35,square:0, point:.25,tri:0, lump:.3, flat:.45},
pilz: {headW:1.25, headH:1.1, headD:1.15, pear:-.9, square:.1, point:0, tri:0, lump:0, flat:.2, waist:.55},
erdnuss:{headW:.95, headH:1.4, headD:.9, pear:.1, square:0, point:0, tri:0, lump:0, flat:.15, waist:.85},
tonne: {headW:1.05, headH:1.3, headD:1.05, pear:0, square:1.0, point:0, tri:0, lump:0, flat:.1},
zipfel: {headW:1.1, headH:1.25,headD:1.0, pear:.45, square:0, point:1.5,tri:0, lump:0, flat:.2, hunch:.35},
knolle: {headW:1.2, headH:1.05,headD:1.0, pear:.4, square:0, point:0, tri:0, lump:1.4,flat:.2},
};
const PATS = ['none','stripes','camo','leopard','spots','patches'];
const SECTIONS = [
{t:'Kopfform', open:true, c:[
['shape','Vorlage',[['mochi','Mochi'],['kugel','Kugel'],['ei','Ei'],['birne','Birne'],['bohne','Bohne'],['onigiri','Onigiri'],['tropfen','Tropfen'],['wuerfel','Würfel'],
['fladen','Fladen'],['stange','Stange'],['sack','Sack'],['pilz','Pilz'],['erdnuss','Erdnuss'],['tonne','Tonne'],['zipfel','Zipfel'],['knolle','Knolle']],'preset'],
['headSize','Größe',.5,1.8,.01],['headW','Breite',.5,1.8,.01],['headH','Höhe',.5,1.8,.01],['headD','Tiefe',.5,1.5,.01],
['pear','Birne',-1,1.4,.01],['square','Eckig',0,1,.01],['point','Spitze',0,1.6,.01],['tri','Onigiri',0,1.2,.01],['lump','Beulen',0,1.5,.01],
['waist','Taille',0,1,.01],['hunch','Buckel',-.6,.6,.01],['flat','Flacher Boden',0,.5,.01]]},
{t:'Gesicht', open:true, c:[
['faceType','Oberfläche',[['fur','Fell'],['smooth','Glatt']]],['faceShape','Form',[['oval','Oval'],['heart','Herz']]],
['faceFit','An Augen & Mund anpassen','bool'],['faceW','Breite (min.)',.1,.9,.01],['faceH','Höhe (min.)',.1,.8,.01],['faceY','Versatz',-25,25,1],
['faceGloss','Glanz',0,1,.01],['faceSoft','Weichheit',0,1,.01],['faceBump','Hautstruktur',0,1,.01],
['mouthType','Mund',[['round','rund'],['oval','oval'],['none','—']]],
['mouthSize','Mundgröße',.02,.2,.005],['mouthW','Breite (oval)',.6,3,.01],['mouthY','Mundhöhe',-55,15,1],
['noseType','Nase',[['none','—'],['dot','Punkt'],['pink','Knöpfchen']]],['noseY','Nasenhöhe',-45,20,1],
['blush','Bäckchen',0,1,.01],['blushSize','Bäckchen Größe',.06,.4,.005],['blushX','Bäckchen Abstand',8,75,1],['blushY','Bäckchen Höhe',-45,25,1]]},
{t:'Augen', c:[
['eyeType','Typ',[['button','Knopf'],['arc','Bogen ◠'],['arcdown','Bogen ◡'],['line','Strich']]],
['eyeShine','Glanzpunkt',[['none','—'],['one','einer'],['two','zwei']]],
['eyeSize','Größe',.05,.3,.005],['eyeX','Abstand',4,45,1],['eyeY','Höhe',-30,40,1],['eyeDepth','Wölbung',.2,1,.01],['eyeLine','Strichstärke',.1,1,.01],['eyeGloss','Glanz',0,1,.01],['eyeColor','Farbe','color']]},
{t:'Arme · Füße · Schwanz', c:[
['armType','Arme',[['nub','Stummel'],['stubby','Ärmchen'],['long','lang'],['none','—']]],
['armSize','Dicke',.08,.35,.005],['armLength','Länge',0,.8,.01],['armX','seitlich',40,130,1],['armY','Höhe',-60,30,1],['armAngle','Abspreizen',0,90,1],
['legType','Füße',[['paws','Pfoten'],['stubby','Beinchen'],['smooth','glatt'],['none','—']]],
['legSize','Größe',.12,.5,.005],['legLength','Länge',0,.6,.01],['legSpread','Abstand',5,45,1],
['tailType','Schwanz',[['pom','Puschel'],['fluffy','Fuchs'],['none','—']]],['tailSize','Schwanzgröße',.2,1.2,.01]]},
{t:'Fell', c:[
['furLength','Länge',0,.6,.005],['furWidth','Haardicke',.004,.06,.0005],['furDensity','Dichte',500,7000,50],['furSegs','Glieder pro Haar',3,12,1],
['furGravity','Schwerkraft',0,2,.01],['furStiff','Steifheit',.1,1.5,.01],['furLean','Strich',0,1,.01],['furCurl','Locken',0,1,.01],['furCurlFreq','Lockenfrequenz',1,25,.5],
['furVar','Unregelmäßig',0,.9,.01],['furWind','Bewegung',0,1,.01],['tuft','Schopf',0,1.5,.01],['furShag','Zottelig',0,1,.01],['furSheen','Glanzkanten',0,1,.01],['furTip','Spitzen heller',0,1,.01],['bellyPatch','Bauchfleck',0,.7,.01],
['bald','Kahle Stellen',0,1,.01],['baldScale','Fleckengröße',.5,2,.01]]},
{t:'Muster', c:[
['patType','Muster',[['none','—'],['stripes','Streifen'],['camo','Camouflage'],['leopard','Leopard'],['spots','Flecken'],['patches','Patches']]],
['patScale','Maßstab',.5,8,.05],['patAmt','Menge',0,1,.01]]},
{t:'Farben', c:[
['colTop','Fell oben','color'],['colBot','Fell unten','color'],['colPat','Muster','color'],['colPat2','Muster 2','color'],
['colPatch','Bauch / Puschel','color'],['colFace','Gesicht glatt','color'],['_face','Gesichtsfarbe',[['comp','komplementär'],['white','weiß'],['black','schwarz']],'faceauto'],['colBlush','Bäckchen','color'],['_pal','Paletten','palettes']]},
{t:'Stimme & Atmung', c:[
['voiceWave','Klang',[['square','Rechteck'],['sine','Sinus'],['saw','Säge'],['mix','Mix']]],
['voicePitch','Tonhöhe',.15,.8,.005],['voiceSpeed','Tempo',.5,2,.01],['voiceRough','Rauheit',0,1,.01],['voiceVib','Vibrato',0,.6,.01],['volume','Lautstärke',0,1,.01],
['breath','Atmung',0,2,.01],['blink','Blinzeln','bool']]}
];
const RANGE = {};
SECTIONS.forEach(s=>s.c.forEach(c=>{ if (typeof c[2]==='number') RANGE[c[0]] = [c[2],c[3]]; }));
const LIVE = new Set(['furSheen','furTip','faceBump','furLength','furWidth','furGravity','furStiff','furLean','furCurl','furCurlFreq','furVar','furWind','patType','patScale','patAmt',
'colTop','colBot','colPat','colPat2','colPatch','colFace','colBlush','colLips','eyeColor','eyeGloss','faceGloss','faceSoft','blush','breath','blink','armAngle',
'voiceWave','voicePitch','voiceSpeed','voiceRough','voiceVib','volume']);
const LAYOUT = new Set(['faceFit','faceW','faceH','faceY','faceShape','eyeX','eyeY','mouthY','noseY','armX','armY','legSpread','blushX','blushY','blushSize']);
const CLEARKEYS = new Set(['faceFit','faceW','faceH','faceY','faceShape','eyeX','eyeY','mouthY','noseY']);
/* ================= RENDERER ================= */
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setClearColor('#ffffff',1);
renderer.toneMapping = THREE.NeutralToneMapping;
const scene = new THREE.Scene();
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), .04).texture;
scene.environmentIntensity = .7;
scene.add(new THREE.HemisphereLight('#ffffff','#f4ebf2',1.0));
const key = new THREE.DirectionalLight('#ffffff',1.4); key.position.set(3,6,4); scene.add(key);
const camera = new THREE.PerspectiveCamera(30,1,.05,100);
camera.position.set(1.4,1.7,6.4);
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let drag = null, down = null;
canvas.addEventListener('pointerdown', onDown);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0,.9,0);
controls.enableDamping = true; controls.dampingFactor = .08;
controls.minDistance = 2; controls.maxDistance = 14;
controls.maxPolarAngle = Math.PI*.53;
controls.enablePan = false;
const sc = document.createElement('canvas'); sc.width = sc.height = 256;
const sctx = sc.getContext('2d');
const grd = sctx.createRadialGradient(128,128,0,128,128,128);
grd.addColorStop(0,'rgba(70,45,70,0.26)'); grd.addColorStop(.45,'rgba(70,45,70,0.11)'); grd.addColorStop(1,'rgba(70,45,70,0)');
sctx.fillStyle = grd; sctx.fillRect(0,0,256,256);
const shadowTex = new THREE.CanvasTexture(sc); shadowTex.colorSpace = THREE.SRGBColorSpace;
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1,1), new THREE.MeshBasicMaterial({map:shadowTex, transparent:true, depthWrite:false, toneMapped:false}));
shadow.rotation.x = -Math.PI/2; shadow.position.y = .003; scene.add(shadow);
/* ================= UNIFORMS (geteilt, live) ================= */
const U = {
time:{value:0}, len:{value:.17}, width:{value:.011}, gravity:{value:.75}, stiff:{value:.5}, wind:{value:.12}, curl:{value:.06}, curlFreq:{value:9},
variance:{value:.45}, lean:{value:.35}, inertia:{value:new V3()}, brush:{value:new THREE.Vector4(0,-99,0,.3)}, brushVel:{value:new V3()},
px:{value:.001}, segs:{value:6},
pat:{value:0}, patScale:{value:3}, patAmt:{value:.5}, yMin:{value:-1}, yMax:{value:1},
colTop:{value:new THREE.Color()}, colBot:{value:new THREE.Color()}, colPat:{value:new THREE.Color()}, colPat2:{value:new THREE.Color()},
colPatch:{value:new THREE.Color()}, colFace:{value:new THREE.Color()}, colBlush:{value:new THREE.Color()},
blushP:{value:[new THREE.Vector4(), new THREE.Vector4()]}, blushAmt:{value:.6},
faceOn:{value:1}, faceF:{value:new V3(0,0,1)}, faceR:{value:new V3(1,0,0)}, faceU:{value:new V3(0,1,0)}, faceA:{value:.5}, faceB:{value:.4}, faceShape:{value:1}, faceGloss:{value:.65}, faceSoft:{value:.5}, faceBump:{value:.35}, sheen:{value:.5}, tip:{value:.3},
lightDir:{value:new V3(.45,.85,.6).normalize()}
};
/* ================= SHADER ================= */
const PATTERN = /* glsl */`
uniform int uPat; uniform float uPatScale, uPatAmt, uYMin, uYMax, uBlushAmt;
uniform vec3 uColTop, uColBot, uColPat, uColPat2, uColBlush;
uniform vec4 uBlushP[2];
float h31(vec3 p){ p = fract(p*0.3183099 + 0.1); p *= 17.0; return fract(p.x*p.y*p.z*(p.x + p.y + p.z)); }
float vnoise(vec3 x){
vec3 i = floor(x); vec3 f = fract(x); f = f*f*(3.0 - 2.0*f);
return mix(mix(mix(h31(i), h31(i + vec3(1,0,0)), f.x), mix(h31(i + vec3(0,1,0)), h31(i + vec3(1,1,0)), f.x), f.y),
mix(mix(h31(i + vec3(0,0,1)), h31(i + vec3(1,0,1)), f.x), mix(h31(i + vec3(0,1,1)), h31(i + vec3(1,1,1)), f.x), f.y), f.z);
}
float fbm(vec3 p){ float a = 0.5, s = 0.0; for (int i = 0; i < 4; i++){ s += a*vnoise(p); p = p*2.03 + vec3(1.7, 9.2, 3.1); a *= 0.5; } return s/0.9375; }
vec3 h33(vec3 p){ return vec3(h31(p), h31(p + vec3(5.2,1.3,7.7)), h31(p + vec3(3.1,8.4,2.6))); }
vec2 worley(vec3 p){
vec3 i = floor(p), f = fract(p); float d = 8.0, id = 0.0;
for (int z = -1; z <= 1; z++) for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++){
vec3 g = vec3(float(x), float(y), float(z)); vec3 r = g + h33(i + g) - f; float dd = dot(r, r);
if (dd < d){ d = dd; id = h31(i + g + vec3(11.0)); }
}
return vec2(sqrt(d), id);
}
vec3 furColor(vec3 p){
float t = clamp((p.y - uYMin)/max(uYMax - uYMin, 1e-3), 0.0, 1.0);
vec3 c = mix(uColBot, uColTop, smoothstep(0.0, 1.0, t));
if (uPat == 0) return c;
vec3 q = p*uPatScale; float A = uPatAmt;
if (uPat == 1){
float w = fbm(q*0.6)*2.2;
float s = sin(q.y*2.6 + w*1.8 + sin(q.x*0.9 + q.z*0.7)*0.8)*0.5 + 0.5 + (fbm(q*2.5) - 0.5)*0.3;
float th = 1.0 - A*0.75;
return mix(c, uColPat, smoothstep(th, th + 0.06, s));
}
if (uPat == 2){
float th = 0.62 - A*0.22;
float m1 = smoothstep(th, th + 0.025, fbm(q*0.55));
float m2 = smoothstep(th, th + 0.025, fbm(q*0.55 + vec3(17.3,4.1,9.7)));
c = mix(c, uColPat2, m2);
return mix(c, uColPat, m1);
}
if (uPat == 3){
vec3 qq = q*0.9 + (vec3(fbm(q*0.8), fbm(q*0.8 + 3.3), fbm(q*0.8 + 7.1)) - 0.5)*0.9;
vec2 w = worley(qq);
float rad = 0.2 + 0.18*A;
float ring = smoothstep(rad + 0.16, rad + 0.1, w.x) * smoothstep(rad - 0.02, rad + 0.03, w.x);
ring *= smoothstep(0.3, 0.5, vnoise(qq*5.0 + w.y*10.0));
float core = 1.0 - smoothstep(rad - 0.05, rad, w.x);
c = mix(c, uColPat2, core*0.85);
return mix(c, uColPat, ring);
}
if (uPat == 4){
vec2 w = worley(q*0.8 + (fbm(q) - 0.5)*0.7);
float rad = (0.12 + 0.28*w.y)*(0.35 + A);
return mix(c, uColPat, 1.0 - smoothstep(rad - 0.03, rad + 0.03, w.x));
}
float th = 0.6 - A*0.2;
c = mix(c, uColPat2, smoothstep(th, th + 0.02, fbm(q*0.35 + vec3(31.7,7.3,5.1))));
return mix(c, uColPat, smoothstep(th, th + 0.02, fbm(q*0.35 + 2.0)));
}
float blushMask(vec3 p){
float b = 0.0;
for (int i = 0; i < 2; i++){ float d = distance(p, uBlushP[i].xyz); b = max(b, 1.0 - smoothstep(uBlushP[i].w*0.2, uBlushP[i].w, d)); }
return b*uBlushAmt;
}`;
const STRAND_VS = /* glsl */`
attribute vec3 iRoot; attribute vec3 iN; attribute vec3 iPat; attribute vec4 iRnd; attribute float iLen; attribute float iMask;
uniform float uSegs, uLen, uWidth, uGravity, uStiff, uWind, uTime, uCurl, uCurlFreq, uVar, uLean, uPx;
uniform vec3 uInertia, uBrushVel, uColPatch; uniform vec4 uBrush;
${PATTERN}
varying float vT; varying float vSide; varying vec3 vTan; varying vec3 vWP; varying vec3 vWN; varying vec3 vCol; varying float vAlpha; varying float vRndY;
vec3 dirAt(float t, vec3 n0, vec3 tA, vec3 tB, vec3 lean, vec3 F, float flex, float ph){
vec3 d = n0 + lean*uLean*(0.25 + t);
d += F*flex*t*1.3;
float a = ph + t*uCurlFreq;
d += (cos(a)*tA + sin(a)*tB)*uCurl*(0.2 + t);
d = normalize(d);
float k = dot(d, n0);
if (k < 0.1) d = normalize(d + n0*(0.1 - k)*1.6);
return d;
}
void main(){
float seg = position.x; float side = position.y;
vec3 r0 = (modelMatrix*vec4(iRoot, 1.0)).xyz;
vec3 n0 = normalize(mat3(modelMatrix)*iN);
vec3 tA = normalize(cross(n0, abs(n0.y) < 0.95 ? vec3(0.0,1.0,0.0) : vec3(1.0,0.0,0.0)));
vec3 tB = cross(n0, tA);
float ang = iRnd.x*6.28318;
vec3 lean = cos(ang)*tA + sin(ang)*tB;
float L = uLen*iLen*mix(1.0, 0.45 + 0.55*iRnd.y, uVar);
vec3 F = vec3(0.0, -uGravity, 0.0) + uInertia;
F += uWind*vec3(sin(uTime*1.6 + r0.x*3.0 + r0.y*2.0 + iRnd.z*2.0), 0.3*sin(uTime*2.1 + r0.z*4.0 + iRnd.w*3.0), cos(uTime*1.2 + r0.z*3.0 + r0.y*1.7 + iRnd.w*2.0));
float bd = distance(r0, uBrush.xyz)/max(uBrush.w, 1e-3);
F += uBrushVel*exp(-bd*bd);
float flex = 1.0/max(uStiff, 0.05);
float ph = iRnd.z*6.28318;
vec3 p = r0;
float sl = L/uSegs;
int ns = int(seg + 0.5);
for (int i = 0; i < 24; i++){
if (i >= ns) break;
p += dirAt((float(i) + 0.5)/uSegs, n0, tA, tB, lean, F, flex, ph)*sl;
}
float tt = seg/uSegs;
vec3 tan = dirAt(clamp(tt, 0.0, 1.0), n0, tA, tB, lean, F, flex, ph);
vec3 V = normalize(cameraPosition - p);
vec3 sv = cross(tan, V); float svl = length(sv); sv = svl > 1e-4 ? sv/svl : tA;
float w = uWidth*(0.7 + 0.6*iRnd.w)*mix(1.0, 0.15, tt);
float pxw = distance(cameraPosition, p)*uPx*1.3;
float alpha = 1.0;
if (w < pxw){ alpha = w/pxw; w = pxw; }
p += sv*side*w*0.5;
vec3 c = furColor(iPat);
c = mix(c, uColPatch, iMask);
c = mix(c, uColBlush, blushMask(iPat));
vCol = c; vT = tt; vSide = side; vTan = tan; vWP = p; vWN = n0; vAlpha = alpha; vRndY = iRnd.y;
gl_Position = projectionMatrix*viewMatrix*vec4(p, 1.0);
}`;
const STRAND_FS = /* glsl */`
uniform vec3 uLightDir; uniform float uSheen, uTip;
varying float vT; varying float vSide; varying vec3 vTan; varying vec3 vWP; varying vec3 vWN; varying vec3 vCol; varying float vAlpha; varying float vRndY;
void main(){
float a = (1.0 - smoothstep(mix(0.98, 0.45, uSheen), 1.0, abs(vSide)))*vAlpha;
a *= 1.0 - smoothstep(0.82, 1.0, vT)*0.6;
if (a < 0.03) discard;
vec3 T = normalize(vTan); vec3 V = normalize(cameraPosition - vWP); vec3 L = uLightDir;
float TL = dot(T, L); float diff = sqrt(max(0.0, 1.0 - TL*TL));
vec3 H = normalize(L + V); float TH = dot(T, H);
float spec = pow(sqrt(max(0.0, 1.0 - TH*TH)), 70.0);
float nl = clamp(dot(normalize(vWN), L)*0.5 + 0.55, 0.0, 1.0);
float ao = mix(0.38, 1.0, smoothstep(0.0, 0.75, vT));
vec3 c = vCol*(0.92 + 0.16*vRndY);
c = mix(c, c*0.55 + 0.45, uTip*smoothstep(0.55, 1.0, vT));
vec3 col = c*(mix(0.55, 0.4, uSheen) + mix(0.5, 0.65, uSheen)*mix(nl, diff*nl, uSheen))*ao + spec*0.18*uSheen*uSheen*nl;
gl_FragColor = vec4(col, a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`;
const SKIN_VS = /* glsl */`
attribute vec3 aDir; attribute vec3 aPat; attribute float aBald;
varying vec3 vWN; varying vec3 vWP; varying vec3 vPat; varying vec3 vDir; varying float vBald;
void main(){
vDir = aDir; vPat = aPat; vBald = aBald;
vWN = normalize(mat3(modelMatrix)*normal);
vec4 wp = modelMatrix*vec4(position, 1.0); vWP = wp.xyz;
gl_Position = projectionMatrix*viewMatrix*wp;
}`;
const SKIN_FS = /* glsl */`
${PATTERN}
uniform vec3 uLightDir, uColFace, uFaceF, uFaceR, uFaceU;
uniform float uFaceOn, uFaceA, uFaceB, uSmooth, uGloss, uSoft, uBump; uniform int uFaceShape;
vec3 bumpN(vec3 N, vec3 p){
if (uBump < 0.001) return N;
float e = 0.004; vec3 q = p*26.0, q2 = p*71.0;
float n0 = vnoise(q) + 0.5*vnoise(q2);
vec3 g = vec3(vnoise(q + vec3(e*26.0,0,0)) + 0.5*vnoise(q2 + vec3(e*71.0,0,0)), vnoise(q + vec3(0,e*26.0,0)) + 0.5*vnoise(q2 + vec3(0,e*71.0,0)), vnoise(q + vec3(0,0,e*26.0)) + 0.5*vnoise(q2 + vec3(0,0,e*71.0))) - n0;
g /= e; g -= N*dot(g, N);
return normalize(N - g*0.0035*uBump);
}
varying vec3 vWN; varying vec3 vWP; varying vec3 vPat; varying vec3 vDir; varying float vBald;
float faceMask(vec3 d){
if (dot(d, uFaceF) < 0.0) return 0.0;
float u = dot(d, uFaceR), v = dot(d, uFaceU), e;
if (uFaceShape == 0) e = length(vec2(u/uFaceA, v/uFaceB));
else {
float e1 = length(vec2((u - uFaceA*0.45)/(uFaceA*0.62), (v - uFaceB*0.25)/(uFaceB*0.7)));
float e2 = length(vec2((u + uFaceA*0.45)/(uFaceA*0.62), (v - uFaceB*0.25)/(uFaceB*0.7)));
float e3 = length(vec2(u/(uFaceA*0.7), (v + uFaceB*0.25)/(uFaceB*0.75)));
e = min(min(e1, e2), e3);
}
return 1.0 - smoothstep(0.94, 1.0, e);
}
void main(){
vec3 N = normalize(vWN); vec3 V = normalize(cameraPosition - vWP); vec3 L = uLightDir;
float m = uSmooth;
if (uFaceOn > 0.5) m = max(m, faceMask(normalize(vDir)));
m = max(m, smoothstep(0.4, 0.6, vBald));
if (m > 0.01) N = bumpN(N, vPat);
float wrap = clamp((dot(N, L) + 0.45)/1.45, 0.0, 1.0);
vec3 fur = furColor(vPat)*0.42*(0.55 + 0.45*wrap);
vec3 sc = mix(uColFace, uColBlush, blushMask(vPat));
vec3 H = normalize(L + V); float nh = max(dot(N, H), 0.0);
float sp = pow(nh, mix(12.0, 160.0, uGloss))*mix(0.03, 1.2, uGloss*uGloss) + pow(nh, 8.0)*0.06*uGloss;
float fr = pow(1.0 - max(dot(N, V), 0.0), 3.0);
float dif = mix(clamp(dot(N, L), 0.0, 1.0), wrap, uSoft);
vec3 sil = sc*(mix(0.42, 0.55, uSoft) + mix(0.68, 0.52, uSoft)*dif) + sc*sc*0.08*uSoft + sp + fr*mix(0.05, 0.4, uGloss)*(sc*0.4 + 0.6);
gl_FragColor = vec4(mix(fur, sil, m), 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`;
const patU = ()=>({uPat:U.pat, uPatScale:U.patScale, uPatAmt:U.patAmt, uYMin:U.yMin, uYMax:U.yMax, uColTop:U.colTop, uColBot:U.colBot,
uColPat:U.colPat, uColPat2:U.colPat2, uBlushP:U.blushP, uBlushAmt:U.blushAmt, uColBlush:U.colBlush});
const STRAND_MAT = new THREE.ShaderMaterial({
uniforms:{uSegs:U.segs, uLen:U.len, uWidth:U.width, uGravity:U.gravity, uStiff:U.stiff, uWind:U.wind, uTime:U.time, uCurl:U.curl, uCurlFreq:U.curlFreq,
uVar:U.variance, uLean:U.lean, uPx:U.px, uSheen:U.sheen, uTip:U.tip, uInertia:U.inertia, uBrushVel:U.brushVel, uBrush:U.brush, uColPatch:U.colPatch, uLightDir:U.lightDir, ...patU()},
vertexShader:STRAND_VS, fragmentShader:STRAND_FS, alphaToCoverage:true, side:THREE.DoubleSide
});
function skinMat(faceOn, smooth){
return new THREE.ShaderMaterial({
uniforms:{...patU(), uLightDir:U.lightDir, uColFace:U.colFace, uFaceF:U.faceF, uFaceR:U.faceR, uFaceU:U.faceU, uFaceA:U.faceA, uFaceB:U.faceB,
uFaceShape:U.faceShape, uBump:U.faceBump, uGloss:U.faceGloss, uSoft:U.faceSoft, uFaceOn:faceOn ? U.faceOn : {value:0}, uSmooth:{value:smooth}},
vertexShader:SKIN_VS, fragmentShader:SKIN_FS
});
}
const SKIN_BODY = skinMat(true,0), SKIN_FUR = skinMat(false,0), SKIN_SMOOTH = skinMat(false,1);
const EYE_MAT = new THREE.MeshPhysicalMaterial({color:'#22160f', roughness:.08, clearcoat:1, clearcoatRoughness:.04});
const HL_MAT = new THREE.MeshBasicMaterial({color:'#ffffff', toneMapped:false});
const LINE_MAT = new THREE.MeshStandardMaterial({color:'#3d2430', roughness:.5});
const INSIDE_MAT = new THREE.MeshStandardMaterial({color:'#8a3550', roughness:.6, side:THREE.DoubleSide});
const TONGUE_MAT = new THREE.MeshStandardMaterial({color:'#ff8fa8', roughness:.5, side:THREE.DoubleSide});
const TOOTH_MAT = new THREE.MeshStandardMaterial({color:'#ffffff', roughness:.3});
const NOSE_MAT = new THREE.MeshPhysicalMaterial({color:'#ff7a9a', roughness:.35, clearcoat:.8});
const MOUTH_MAT = new THREE.MeshPhysicalMaterial({color:'#3a2230', roughness:.25, clearcoat:.8, clearcoatRoughness:.1});
/* ================= GEOMETRIE ================= */
const SPHERE_GEO = new THREE.SphereGeometry(1,24,16); SPHERE_GEO.userData.shared = true;
const SPHERE_HI = new THREE.SphereGeometry(1,48,32); SPHERE_HI.userData.shared = true;
const sphereCache = {};
function unitSphere(detail){
if (!sphereCache[detail]){
let g = new THREE.IcosahedronGeometry(1, detail);
g.deleteAttribute('normal'); g.deleteAttribute('uv');
sphereCache[detail] = mergeVertices(g);
}
return sphereCache[detail];
}
function makeGeo(detail, fn){
const base = unitSphere(detail), src = base.attributes.position;
const pos = new Float32Array(src.count*3), dir = new Float32Array(src.count*3);
const d = new V3(), o = new V3();
for (let i=0;i<src.count;i++){
d.fromBufferAttribute(src,i).normalize(); fn(d,o);
pos[i*3]=o.x; pos[i*3+1]=o.y; pos[i*3+2]=o.z;
dir[i*3]=d.x; dir[i*3+1]=d.y; dir[i*3+2]=d.z;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos,3));
g.setAttribute('aDir', new THREE.BufferAttribute(dir,3));
g.setAttribute('aPat', new THREE.BufferAttribute(new Float32Array(src.count*3),3));
g.setIndex(base.index.clone());
g.computeVertexNormals(); g.computeBoundingBox(); g.computeBoundingSphere();
return g;
}
function shapeF(s, d, out = new V3()){
const e = 2 + s.square*2.6;
const r = Math.pow(Math.pow(Math.abs(d.x),e) + Math.pow(Math.abs(d.y),e) + Math.pow(Math.abs(d.z),e), -1/e);
let x = d.x*r, y = d.y*r, z = d.z*r;
const t = (clamp(y,-1,1) + 1)/2;
let w = 1 + s.pear*(-y)*0.5;
w *= 1 + s.tri*(0.32 - 0.64*t);
const up = Math.max(y,0);
w *= 1 - s.point*0.35*up*up;
y += s.point*up*up*up*0.55;
if (s.waist) w *= 1 - s.waist*0.42*Math.exp(-Math.pow((y - .05)/.38, 2));
let rr = 1;
if (s.lump) rr += s.lump*(0.07*Math.sin(3.1*d.x + 1.3)*Math.sin(2.3*d.y + 0.7)*Math.cos(2.7*d.z + 0.4) + 0.05*Math.sin(5*d.x + 2*d.z));
x *= w*rr*s.rx; z *= w*rr*s.rz; y *= rr*s.ry;
if (s.hunch) z -= s.hunch*s.rz*(y/s.ry)*(y/s.ry)*.6;
const fy = -s.ry*(1 - s.flat);
if (y < fy) y = fy + (y - fy)*0.3;
return out.set(x,y,z);
}
function surf(s, d){
const p = shapeF(s,d);
const t1 = new V3().crossVectors(d, Math.abs(d.y) < .95 ? Y : X).normalize();
const t2 = new V3().crossVectors(d, t1).normalize();
const e = .01;
const p1 = shapeF(s, d.clone().addScaledVector(t1,e).normalize()).sub(p);
const p2 = shapeF(s, d.clone().addScaledVector(t2,e).normalize()).sub(p);
const n = new V3().crossVectors(p1,p2).normalize();
if (n.dot(p) < 0) n.negate();
return {p,n};
}
function invShape(s, local){
const d = new V3(local.x/s.rx, local.y/s.ry, local.z/s.rz).normalize();
const Rm = (s.rx+s.ry+s.rz)/3;
for (let i=0;i<8;i++){ const f = shapeF(s,d); d.addScaledVector(local.clone().sub(f), 1/Rm).normalize(); }
return d;
}
function dirYP(yaw, pitch){
const cy = Math.cos(pitch*D2R);
return new V3(Math.sin(yaw*D2R)*cy, Math.sin(pitch*D2R), Math.cos(yaw*D2R)*cy);
}
function limbFn(o){
return (d,out)=>{
const t = (d.y+1)/2;
const pr = Math.hypot(d.x,d.z);
const k = pr > 1e-6 ? Math.pow(pr, o.round ?? 1)/pr : 0;
const tp = Math.max(.04, 1 - (o.taper||0)*t);
let x = d.x*k*o.w*tp, z = d.z*k*o.d*tp, y = t*o.len;
x += (o.bendX||0)*t*t*o.len; z += (o.bendZ||0)*t*t*o.len;
return out.set(x,y,z);
};
}
function placeOnSurface(obj, S, yaw, pitch, out=0, fwd=.3){
const {p,n} = surf(S, dirYP(yaw,pitch));
const dir = n.clone().lerp(Z, fwd).normalize();
obj.position.copy(p).addScaledVector(dir, out);
obj.quaternion.setFromUnitVectors(Z, dir);
return {p,n};
}
function faceE(d){
if (d.dot(U.faceF.value) < 0) return 9;
const u = d.dot(U.faceR.value), v = d.dot(U.faceU.value), A = U.faceA.value, B = U.faceB.value;
if (U.faceShape.value === 0) return Math.hypot(u/A, v/B);
return Math.min(
Math.hypot((u - A*.45)/(A*.62), (v - B*.25)/(B*.7)),
Math.hypot((u + A*.45)/(A*.62), (v - B*.25)/(B*.7)),
Math.hypot(u/(A*.7), (v + B*.25)/(B*.75)));
}
/* ================= RAUSCHEN ================= */
function h3(x,y,z,s){ let h = (Math.imul(x,374761393) + Math.imul(y,668265263) + Math.imul(z,1442695041) + Math.imul(s,1274126177))|0; h = Math.imul(h ^ (h>>>13), 1274126177); return ((h ^ (h>>>16))>>>0)/4294967296; }
function vnoise3(x,y,z,s){
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
let fx = x - xi, fy = y - yi, fz = z - zi; fx = fx*fx*(3-2*fx); fy = fy*fy*(3-2*fy); fz = fz*fz*(3-2*fz);
const L = (a,b,t)=>a + (b-a)*t;
return L(L(L(h3(xi,yi,zi,s), h3(xi+1,yi,zi,s), fx), L(h3(xi,yi+1,zi,s), h3(xi+1,yi+1,zi,s), fx), fy),
L(L(h3(xi,yi,zi+1,s), h3(xi+1,yi,zi+1,s), fx), L(h3(xi,yi+1,zi+1,s), h3(xi+1,yi+1,zi+1,s), fx), fy), fz);
}
function fbm3(x,y,z,s){ return (vnoise3(x,y,z,s)*.5 + vnoise3(x*2.03+5.1,y*2.03,z*2.03,s+1)*.25 + vnoise3(x*4.1,y*4.1+3.3,z*4.1,s+2)*.125)/.875; }
// große, runde, links/rechts gespiegelte Flecken (Kugelkappen auf der Kopfrichtung)
let _baldKey = '', _baldSpots = [];
function baldSpots(){
const key = P.seed + '|' + P.bald + '|' + P.baldScale;
if (key === _baldKey) return _baldSpots;
_baldKey = key; _baldSpots = [];
const r = mulberry(((P.seed >>> 0) ^ 0x2545f491) >>> 0);
const n = 1 + Math.round(P.bald*4);
for (let i=0;i<n;i++){
// Seiten, Oberkopf und Rücken – nicht ins Gesicht
const d = dirYP(50 + r()*130, -35 + r()*95);
const rad = (.28 + r()*.3)*P.baldScale*(.7 + P.bald*.5);
const ca = Math.cos(Math.min(1.4, rad)), cb = Math.cos(Math.min(1.4, rad*.82));
_baldSpots.push({d, ca, cb}, {d:new V3(-d.x, d.y, d.z), ca, cb});
}
return _baldSpots;
}
function baldMask(d){
if (P.bald < .01) return 0;
let m = 0;
for (const sp of baldSpots()) m = Math.max(m, smoothstep(sp.ca, sp.cb, d.dot(sp.d)));
return m;
}
/* ================= STRÄHNEN ================= */
function makeStrands(geo, patM, rng, opt){
const pos = geo.attributes.position.array, nor = geo.attributes.normal.array, dir = geo.attributes.aDir.array, idx = geo.index.array;
const nt = idx.length/3, cum = new Float32Array(nt);
const a = new V3(), b = new V3(), c = new V3(), e1 = new V3(), e2 = new V3();
let tot = 0;
for (let t=0;t<nt;t++){
a.fromArray(pos, idx[t*3]*3); b.fromArray(pos, idx[t*3+1]*3); c.fromArray(pos, idx[t*3+2]*3);
tot += e1.subVectors(b,a).cross(e2.subVectors(c,a)).length()*.5; cum[t] = tot;
}
const N = Math.min(140000, Math.round(tot*P.furDensity*(opt.dens ?? 1)));
if (N < 1) return null;
const root = new Float32Array(N*3), nn = new Float32Array(N*3), pat = new Float32Array(N*3), rnd = new Float32Array(N*4), len = new Float32Array(N), msk = new Float32Array(N);
const p = new V3(), n = new V3(), d = new V3(), q = new V3(), tmp = new V3();
let k = 0;
for (let i=0;i<N;i++){
const r = rng()*tot;
let lo = 0, hi = nt-1; while (lo < hi){ const mid = (lo+hi)>>1; if (cum[mid] < r) lo = mid+1; else hi = mid; }
let u = rng(), v = rng(); if (u+v > 1){ u = 1-u; v = 1-v; } const w = 1-u-v;
const ia = idx[lo*3]*3, ib = idx[lo*3+1]*3, ic = idx[lo*3+2]*3;
p.set(pos[ia]*w + pos[ib]*u + pos[ic]*v, pos[ia+1]*w + pos[ib+1]*u + pos[ic+1]*v, pos[ia+2]*w + pos[ib+2]*u + pos[ic+2]*v);
n.set(nor[ia]*w + nor[ib]*u + nor[ic]*v, nor[ia+1]*w + nor[ib+1]*u + nor[ic+1]*v, nor[ia+2]*w + nor[ib+2]*u + nor[ic+2]*v).normalize();
d.set(dir[ia]*w + dir[ib]*u + dir[ic]*v, dir[ia+1]*w + dir[ib+1]*u + dir[ic+1]*v, dir[ia+2]*w + dir[ib+2]*u + dir[ic+2]*v).normalize();
let L = 1, M = 0;
if (opt.fn){ const res = opt.fn(p,n,d); if (!res) { rng(); rng(); rng(); rng(); continue; } L = res.len; M = res.mask || 0; }
L *= opt.len ?? 1;
tmp.copy(p).addScaledVector(n, -.002);
root[k*3]=tmp.x; root[k*3+1]=tmp.y; root[k*3+2]=tmp.z;
nn[k*3]=n.x; nn[k*3+1]=n.y; nn[k*3+2]=n.z;
q.copy(p).applyMatrix4(patM);
pat[k*3]=q.x; pat[k*3+1]=q.y; pat[k*3+2]=q.z;
rnd[k*4]=rng(); rnd[k*4+1]=rng(); rnd[k*4+2]=rng(); rnd[k*4+3]=rng();
len[k] = L; msk[k] = M; k++;
}
if (!k) return null;
const S = Math.round(P.furSegs);
const vp = new Float32Array((S+1)*6), ind = [];
for (let l=0;l<=S;l++){
vp.set([l,-1,0], l*6); vp.set([l,1,0], l*6+3);
if (l < S){ const a0 = l*2; ind.push(a0, a0+1, a0+2, a0+1, a0+3, a0+2); }
}
const g = new THREE.InstancedBufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(vp,3)); g.setIndex(ind);
g.setAttribute('iRoot', new THREE.InstancedBufferAttribute(root.slice(0,k*3),3));
g.setAttribute('iN', new THREE.InstancedBufferAttribute(nn.slice(0,k*3),3));
g.setAttribute('iPat', new THREE.InstancedBufferAttribute(pat.slice(0,k*3),3));
g.setAttribute('iRnd', new THREE.InstancedBufferAttribute(rnd.slice(0,k*4),4));
g.setAttribute('iLen', new THREE.InstancedBufferAttribute(len.slice(0,k),1));
g.setAttribute('iMask', new THREE.InstancedBufferAttribute(msk.slice(0,k),1));
g.instanceCount = k;
const m = new THREE.Mesh(g, STRAND_MAT);
m.frustumCulled = false; m.raycast = ()=>{};
rig.strandCount += k;
return m;
}
function setAPat(geo, M){
const pos = geo.attributes.position, pat = geo.attributes.aPat, v = new V3();
for (let i=0;i<pos.count;i++){ v.fromBufferAttribute(pos,i).applyMatrix4(M); pat.setXYZ(i, v.x, v.y, v.z); }
pat.needsUpdate = true;
}
/* ================= BUILD ================= */
let creature = null, rig = null, targetY = .9, targetDist = 6.6;
const BELLY = new V3(0,-.5,1).normalize();
function tag(m, key){ m.userData.pick = true; if (key) m.userData.drag = key; rig.pickables.push(m); }
function disposeTree(o){ o.traverse(c=>{ if (c.geometry && !c.geometry.userData.shared) c.geometry.dispose(); }); }
function syncLive(){
U.len.value = P.furLength*P.headSize; U.width.value = P.furWidth*P.headSize;
U.gravity.value = P.furGravity; U.stiff.value = P.furStiff; U.wind.value = P.furWind*.5;
U.curl.value = P.furCurl; U.curlFreq.value = P.furCurlFreq; U.variance.value = P.furVar; U.lean.value = P.furLean;
U.pat.value = Math.max(0, PATS.indexOf(P.patType)); U.patScale.value = P.patScale; U.patAmt.value = P.patAmt;
U.colTop.value.set(P.colTop); U.colBot.value.set(P.colBot); U.colPat.value.set(P.colPat); U.colPat2.value.set(P.colPat2);
U.colPatch.value.set(P.colPatch); U.colFace.value.set(P.colFace); U.colBlush.value.set(P.colBlush);
U.blushAmt.value = P.blush;
EYE_MAT.color.set(P.eyeColor); EYE_MAT.roughness = .03 + (1 - P.eyeGloss)*.45;
NOSE_MAT.color.set(P.colBlush).lerp(new THREE.Color('#ff5f8a'), .35);
U.faceGloss.value = P.faceGloss; U.faceSoft.value = P.faceSoft; U.faceBump.value = P.faceBump ?? .35;
U.sheen.value = P.furSheen ?? .5; U.tip.value = P.furTip ?? .3;
MOUTH_MAT.color.set(lum(P.colFace) < .22 ? '#f2e9e4' : '#3a2230');
if (voiceGain) voiceGain.gain.value = P.volume;
}
function syncFace(S){
U.faceShape.value = P.faceShape==='heart' ? 1 : 0;
U.faceOn.value = P.faceType==='smooth' ? 1 : 0;
S = S || (rig && rig.S);
let cy = P.faceY; const pts = [];
if (P.faceFit && S){
const R0 = Math.max(.2, surf(S, dirYP(P.eyeX, P.eyeY)).p.length());
const ea = Math.atan(P.eyeSize*P.headSize/R0)/D2R*1.3;
const hasM = P.mouthType !== 'none';
const mw = Math.atan(P.mouthSize*(P.mouthType==='round' ? 1 : P.mouthW)*P.headSize*.5/R0)/D2R*1.5;
const mh = Math.atan(P.mouthSize*P.headSize*.5/R0)/D2R*(P.mouthType==='round' ? 1.6 : 1.2);
const top = P.eyeY + ea, bot = hasM ? Math.min(P.mouthY - mh, P.eyeY - ea) : P.eyeY - ea;
cy = (top + bot)/2 + P.faceY;
for (const sd of [-1,1]) for (let k=0;k<16;k++){ const a = k/16*Math.PI*2; pts.push(dirYP(sd*P.eyeX + Math.cos(a)*ea, P.eyeY + Math.sin(a)*ea)); }
if (hasM) for (let k=0;k<16;k++){ const a = k/16*Math.PI*2; pts.push(dirYP(Math.cos(a)*mw, P.mouthY + Math.sin(a)*mh)); }
if (P.noseType !== 'none') pts.push(dirYP(0, P.noseY));
}
const f = dirYP(0, cy), r = new V3().crossVectors(Y,f).normalize(), u = new V3().crossVectors(f,r);
U.faceF.value.copy(f); U.faceR.value.copy(r); U.faceU.value.copy(u);
U.faceA.value = P.faceW; U.faceB.value = P.faceH;
for (let it=0; it<120 && pts.length; it++){
let worst = 0, wp = null;
for (const d of pts){ const e = faceE(d); if (e > worst){ worst = e; wp = d; } }
if (worst < .8) break;
const uu = Math.abs(wp.dot(r))/U.faceA.value, vv = Math.abs(wp.dot(u))/U.faceB.value;
if (uu >= vv) U.faceA.value = Math.min(1.3, U.faceA.value*1.04); else U.faceB.value = Math.min(1.3, U.faceB.value*1.04);
}
}
function build(){
if (creature){ scene.remove(creature); disposeTree(creature); }
syncLive();
rig = {pickables:[], arms:[], feet:[], eyes:[], parts:[], clears:[], strandCount:0};
const rng = mulberry(P.seed);
const hs = P.headSize;
const S = {rx:hs*P.headW, ry:hs*P.headH, rz:hs*P.headD, pear:P.pear, square:P.square, point:P.point, tri:P.tri, lump:P.lump, flat:P.flat, waist:P.waist||0, hunch:P.hunch||0};
rig.S = S;
syncFace(S);
const root = new THREE.Group(); rig.root = root;
const pivot = new THREE.Group(); root.add(pivot); rig.pivot = pivot;
const body = new THREE.Group(); pivot.add(body); rig.body = body;
const bottomY = shapeF(S, new V3(0,-1,0)).y, topY = shapeF(S, new V3(0,1,0)).y;
body.position.y = -bottomY; rig.bottomY = bottomY;
U.yMin.value = bottomY; U.yMax.value = topY;
const bodyMesh = new THREE.Mesh(makeGeo(40,(d,o)=>shapeF(S,d,o)), SKIN_BODY);
{ const gg = bodyMesh.geometry, dd = gg.attributes.aDir, ab = new Float32Array(dd.count), v = new V3();
for (let i=0;i<dd.count;i++) ab[i] = baldMask(v.fromBufferAttribute(dd,i));
gg.setAttribute('aBald', new THREE.BufferAttribute(ab,1)); }
tag(bodyMesh); body.add(bodyMesh); rig.bodyMesh = bodyMesh;
rig.parts.push({mesh:bodyMesh, fur:{fn:(p,n,d)=>{
let L = 1;
if (U.faceOn.value){ const e = faceE(d); if (e < 1) return null; L *= .3 + .7*smoothstep(1, 1.35, e); }
for (const c of rig.clears){ const dist = p.distanceTo(c.p); if (dist < c.r) return null; L *= .2 + .8*smoothstep(c.r, c.r*2.1, dist); }
const Bm = baldMask(d); if (Bm > .5) return null; L *= .3 + .7*(1 - smoothstep(.12, .5, Bm));
L *= Math.max(.15, 1 + P.furShag*(fbm3(d.x*2.2 + 3.1, d.y*2.2, d.z*2.2, ((P.seed>>>0) & 0xffff) + 9) - .5)*2.6);
L *= 1 + P.tuft*2.2*smoothstep(.72, .97, d.y);
L *= .5 + .5*smoothstep(-.97, -.6, d.y);
const mask = P.bellyPatch > .01 ? smoothstep(1 - P.bellyPatch - .08, 1 - P.bellyPatch + .06, d.dot(BELLY)) : 0;
return {len:L, mask};
}}});
// Augen
const er = P.eyeSize*hs;
const shine = P.eyeShine==='none' ? [] : P.eyeShine==='one' ? [[-.36,.4,.17]] : [[-.36,.4,.17],[.34,-.34,.08]];
for (const side of [-1,1]){
const g = new THREE.Group(), bl = new THREE.Group(); g.add(bl);
if (P.eyeType === 'button' || !P.eyeType){
const sz = er*P.eyeDepth;
const m = new THREE.Mesh(SPHERE_HI, EYE_MAT); m.scale.set(er,er,sz); m.position.z = -sz*.3; bl.add(m); tag(m,'eye');
for (const [hx,hy,hr] of shine){
const h = new THREE.Mesh(SPHERE_GEO, HL_MAT); const r = hr*er;
h.scale.set(r,r,r*.4);
h.position.set(hx*er, hy*er, -sz*.3 + sz*Math.sqrt(Math.max(0,1 - hx*hx - hy*hy)) + r*.05);
bl.add(h);
}
} else {
const lw = er*(.05 + P.eyeLine*.15), pts = [];
for (let i=0;i<=24;i++){
const k = i/24*2 - 1; let y = 0;
if (P.eyeType === 'arc') y = Math.sqrt(1 - k*k)*er*.6 - er*.25;
else if (P.eyeType === 'arcdown') y = -Math.sqrt(1 - k*k)*er*.6 + er*.25;
const x = k*er*(P.eyeType === 'line' ? .9 : 1);
pts.push(new V3(x, y, -(x*x + y*y)/(2*S.rz) + lw*.3));
}
const m = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 48, lw, 10, false), EYE_MAT); bl.add(m); tag(m,'eye');
for (const pe of [pts[0], pts[24]]){ const c = new THREE.Mesh(SPHERE_GEO, EYE_MAT); c.scale.setScalar(lw); c.position.copy(pe); bl.add(c); tag(c,'eye'); }
}
body.add(g); rig.eyes.push({g, blink:bl, side, r:er*1.15});
}
// Mund & Nase
const ms = P.mouthSize*hs;
if (P.mouthType !== 'none'){
const round = P.mouthType === 'round';
const MW = ms*.5*(round ? 1 : P.mouthW), MH = ms*.5*(round ? 1 : .42);
const mg = buildMouth(MW, MH, S.rz);
mg.traverse(c=>{ if (c.isMesh) tag(c,'mouth'); });
body.add(mg); rig.mouth = mg; rig.mouthR = Math.max(MW, MH)*1.4;
}
if (P.noseType !== 'none'){
const ns = .03*hs*(P.noseType==='pink' ? 1.4 : 1);
const m = new THREE.Mesh(SPHERE_GEO, P.noseType==='pink' ? NOSE_MAT : LINE_MAT);
m.scale.set(ns*1.3, ns*.9, ns*.8); tag(m,'nose'); body.add(m);
rig.nose = m; rig.noseR = ns;
}
// Arme
if (P.armType !== 'none'){
const ar = P.armSize*hs;
for (const side of [-1,1]){
const aRoot = new THREE.Group(), swing = new THREE.Group(); aRoot.add(swing);
let spec;
if (P.armType==='nub') spec = {w:ar, d:ar, len:ar*1.7, taper:-.05, round:.8};
else if (P.armType==='stubby') spec = {w:ar*.9, d:ar*.9, len:ar + P.armLength*hs, taper:-.3, round:.7};
else spec = {w:ar*.75, d:ar*.75, len:ar + P.armLength*hs*2.2, taper:-.35, round:.7, bendZ:.15};
const mesh = new THREE.Mesh(makeGeo(16, limbFn(spec)), SKIN_FUR);
mesh.rotation.z = Math.PI; swing.add(mesh); tag(mesh,'arm'); body.add(aRoot);
rig.arms.push({root:aRoot, swing, side, ar});
rig.parts.push({mesh, fur:{len:.6}});
}
}
// Füße
if (P.legType !== 'none'){
const fs = P.legSize*hs, ll = P.legLength*hs;
for (const side of [-1,1]){
const fRoot = new THREE.Group(), lift = new THREE.Group(); fRoot.add(lift); root.add(fRoot);
if (P.legType==='stubby' || ll > .02){
const lw = fs*(P.legType==='stubby' ? .55 : .32);
const m = new THREE.Mesh(makeGeo(14, limbFn({w:lw, d:lw, len:ll + fs*.35, taper:-.1, round:.75})), P.legType==='smooth' ? SKIN_SMOOTH : SKIN_FUR);
m.rotation.z = Math.PI; m.position.y = fs*.25; lift.add(m); tag(m,'leg');
rig.parts.push({mesh:m, fur:P.legType==='smooth' ? null : {len:.55}});
}
const fy = -ll - fs*.05;
if (P.legType==='paws'){
const m = new THREE.Mesh(makeGeo(14,(d,o)=>o.set(d.x*fs*.55, d.y*fs*.38, d.z*fs*.75)), SKIN_FUR);
m.position.set(0, fy, fs*.22); lift.add(m); tag(m,'leg');
rig.parts.push({mesh:m, fur:{len:.45}});
} else if (P.legType==='smooth'){
const m = new THREE.Mesh(makeGeo(14,(d,o)=>o.set(d.x*fs*.6, d.y*fs*.3, d.z*fs*.9)), SKIN_SMOOTH);
m.position.set(0, fy, fs*.3); lift.add(m); tag(m,'leg'); rig.parts.push({mesh:m, fur:null});
for (const kx of [-1,0,1]){
const t = new THREE.Mesh(makeGeo(8,(d,o)=>o.set(d.x*fs*.19, d.y*fs*.17, d.z*fs*.21)), SKIN_SMOOTH);
t.position.set(kx*fs*.3, fy - fs*.05, fs*1.02 - Math.abs(kx)*fs*.12); lift.add(t); tag(t,'leg'); rig.parts.push({mesh:t, fur:null});
}
} else if (P.legType==='stubby'){ /* Bein ist der Fuß */ }
rig.feet.push({root:fRoot, lift, side});
}
}
// Schwanz
if (P.tailType !== 'none'){
const ts = P.tailSize*hs;
const tRoot = new THREE.Group(), wag = new THREE.Group(); tRoot.add(wag); body.add(tRoot);
if (P.tailType==='pom'){
const r = ts*.3;
const m = new THREE.Mesh(makeGeo(16,(d,o)=>o.copy(d).multiplyScalar(r)), SKIN_FUR); wag.add(m); tag(m);
rig.parts.push({mesh:m, fur:{len:1.5, dens:1.3, fn:()=>({len:1, mask:1})}});
} else {
const orient = new THREE.Group(); orient.quaternion.setFromUnitVectors(Y, new V3(0,.75,-1).normalize()); wag.add(orient);
const len = ts*1.2;
const m = new THREE.Mesh(makeGeo(16, limbFn({w:ts*.26, d:ts*.26, len, taper:.55, round:.9, bendZ:-.4})), SKIN_FUR); orient.add(m); tag(m);
rig.parts.push({mesh:m, fur:{len:1.8, fn:(p)=>({len:1, mask:smoothstep(.55,.9,p.y/len)})}});
}
rig.tail = {root:tRoot, wag, ts};
}
layout();
// Bodenkontakt
root.updateMatrixWorld(true);
const box = new THREE.Box3();
for (const part of rig.parts) box.expandByObject(part.mesh);
rig.baseY = -box.min.y; root.position.y = rig.baseY;
root.updateMatrixWorld(true);
const size = box.getSize(new V3());
rig.shadowS = Math.max(size.x, size.z)*1.5;
targetY = rig.baseY + (box.min.y + box.max.y)/2;
targetDist = clamp(2.6 + Math.max(size.x, size.y*1.15)*2.0, 3.2, 14);
// Musterkoordinaten + Strähnen
const bodyInv = body.matrixWorld.clone().invert();
for (const part of rig.parts){
const M = new THREE.Matrix4().multiplyMatrices(bodyInv, part.mesh.matrixWorld);
setAPat(part.mesh.geometry, M);
if (part.fur && P.furDensity > 0){ const s = makeStrands(part.mesh.geometry, M, rng, part.fur); if (s) part.mesh.add(s); }
}
U.segs.value = Math.round(P.furSegs);
scene.add(root); creature = root;
setPending(false);
}
function layout(){
if (!rig) return;
const S = rig.S; rig.clears.length = 0;
for (const e of rig.eyes){ const {p} = placeOnSurface(e.g, S, e.side*P.eyeX, P.eyeY, 0, .45); rig.clears.push({p, r:e.r}); }
if (rig.mouth){ const {p} = placeOnSurface(rig.mouth, S, 0, P.mouthY, .006, .25); rig.clears.push({p, r:rig.mouthR}); }
if (rig.nose){ const {p} = placeOnSurface(rig.nose, S, 0, P.noseY, rig.noseR*.3, .25); rig.clears.push({p, r:rig.noseR*2.2}); }
for (const a of rig.arms){ const {p,n} = surf(S, dirYP(a.side*P.armX, P.armY)); a.root.position.copy(p).addScaledVector(n, -a.ar*.45); }
for (const f of rig.feet){ const {p} = surf(S, dirYP(f.side*P.legSpread, -64)); f.root.position.set(p.x, p.y - rig.bottomY, p.z); }
if (rig.tail){ const {p,n} = surf(S, dirYP(180,-20)); rig.tail.root.position.copy(p).addScaledVector(n, -rig.tail.ts*.1); }
for (let i=0;i<2;i++){ const {p} = surf(S, dirYP((i ? 1 : -1)*P.blushX, P.blushY)); U.blushP.value[i].set(p.x, p.y, p.z, P.blushSize*P.headSize); }
syncFace(S);
}
function buildMouth(W, H, Rr){
const g = new THREE.Group(), og = new THREE.Group(); g.add(og);
const geo = new THREE.SphereGeometry(1, 48, 24), p = geo.attributes.position;
const D = Math.min(W, H)*.35;
for (let i=0;i<p.count;i++){
const x = p.getX(i)*W, y = p.getY(i)*H;
p.setXYZ(i, x, y, p.getZ(i)*D - (x*x + y*y)/(2*Rr));
}
geo.computeVertexNormals();
og.add(new THREE.Mesh(geo, MOUTH_MAT));
g.userData.setOpen = o=> og.scale.set(1 - o*.12, 1 + o*1.6, 1);
return g;
}
/* ================= ZUFALL ================= */
const SY = ['mo','chi','pu','ku','ri','nya','po','mi','fu','yu','ni','ko','to','ru','pi','ma','nu','mu','ta','su','pe','ro','wa','hi','no','pa'];
const SUF = ['','','','-chan','maru','pyon','-tan','mon','rin'];
function makeName(){
let n = ''; const k = chance(.35) ? 3 : 2;
for (let i=0;i<k;i++) n += pick(SY);
if (chance(.25)) n += n.slice(-2);
n += pick(SUF);
return n[0].toUpperCase() + n.slice(1);
}
const lum = hex => { const c = new THREE.Color(hex); return .2126*c.r + .7152*c.g + .0722*c.b; };
const hslHex = (h,s,l)=> '#' + new THREE.Color().setHSL(((h%1)+1)%1, clamp(s,0,1), clamp(l,0,1), THREE.SRGBColorSpace).getHexString(THREE.SRGBColorSpace);
function faceColorFor(mode){
const h = {}; new THREE.Color(P.colTop).getHSL(h, THREE.SRGBColorSpace);
if (mode === 'white') return '#f6f3ef';
if (mode === 'black') return '#1f1b20';
return hslHex(h.h + .5 + R(-.06,.06), R(.12,.28), R(.68,.84)); // ungesättigter Komplementärkontrast
}
function applyFaceColor(mode){
P.colFace = faceColorFor(mode);
const dark = lum(P.colFace) < .22;
if (dark && lum(P.eyeColor) < .3) P.eyeColor = '#f1ece6';
if (!dark && lum(P.eyeColor) > .5) P.eyeColor = '#22160f';
}
function contrastFur(){
// Fellfarben mit deutlicherem Hell/Dunkel-Kontrast, weiterhin pastellig
const h = R(0,1), s = R(.35,.75), up = chance(.5);
const light = hslHex(h, s, R(.82,.92)), deep = hslHex(h + R(-.08,.08), s*R(.8,1.1), R(.48,.64));
P.colTop = up ? light : deep; P.colBot = up ? deep : light;
P.colPat = chance(.5) ? hslHex(h + R(-.05,.05), s, R(.25,.4)) : hslHex(h + .5 + R(-.1,.1), s*.8, R(.45,.6));
P.colPat2 = hslHex(h + R(.1,.25), s*.7, R(.72,.85));
P.colPatch = hslHex(h, s*.35, R(.9,.96));
P.colBlush = hslHex(R(.94,1.02), R(.6,.85), R(.72,.8));
P.colLips = P.colBlush;
}
function jitter(hex, a=.025){ const c = new THREE.Color(hex), h = {}; c.getHSL(h); c.setHSL((h.h + R(-a,a) + 1)%1, h.s, h.l); return '#'+c.getHexString(); }
function applyPalette(pl, j=0){
for (const k of ['colTop','colBot','colPat','colPat2','colPatch','colFace','colBlush','eyeColor']) P[k] = j ? jitter(pl[k],j) : pl[k];
P.colLips = '#' + new THREE.Color(pl.colBlush).lerp(new THREE.Color('#c8505f'), .35).getHexString();
}
function applyShape(name){ Object.assign(P, {waist:0, hunch:0}, SHAPES[name]); P.shape = name; }
function randomize(){
P.seed = Math.floor(Math.random()*1e9);
applyShape(pick(Object.keys(SHAPES)));
P.headSize = pick([()=>R(.55,.75), ()=>R(.8,1.15), ()=>R(.8,1.15), ()=>R(1.25,1.7)])();
P.headW *= R(.85,1.15); P.headH *= R(.85,1.15); P.headD *= R(.9,1.1);
if (chance(.3)) P.pear = clamp(P.pear + R(-.4,.5), -1, 1.4);
if (chance(.25)) P.waist = Math.max(P.waist, R(.2,.7));
if (chance(.2)) P.hunch = R(-.4,.4);
if (chance(.2)) P.lump = Math.max(P.lump, R(.3,1.2));
P.faceType = chance(.6) ? 'smooth' : 'fur'; P.faceShape = chance(.5) ? 'oval' : 'heart';
P.faceW = R(.18,.4); P.faceH = R(.18,.35); P.faceY = R(-3,3); P.faceFit = true; P.faceGloss = R(.3,.95); P.faceSoft = R(.3,.8);
P.eyeType = pick(['button','button','button','button','arc','arcdown','line']); P.eyeShine = pick(['one','one','two','none']); P.eyeLine = R(.2,.6);
P.eyeSize = P.eyeType === 'button' ? R(.07,.22) : R(.1,.17); P.eyeX = R(15,26); P.eyeY = R(-1,10); P.eyeDepth = R(.4,.8); P.eyeGloss = R(.65,1);
P.mouthType = pick(['round','oval','oval']);
P.mouthSize = P.mouthType === 'round' ? R(.03,.07) : R(.05,.12); P.mouthW = R(1.2,2.4); P.mouthY = P.eyeY - R(14,22);
P.voiceWave = pick(['square','square','sine','sine','saw','mix']);
P.voicePitch = R(.28,.52);
P.voiceSpeed = R(.8,1.35); P.voiceRough = R(.15,.8); P.voiceVib = chance(.4) ? R(.1,.3) : R(0,.08);
P.noseType = pick(['none','dot','dot','pink']); P.noseY = P.mouthY + R(5,8);
P.blush = chance(.8) ? R(.4,.8) : 0; P.blushSize = R(.13,.22); P.blushX = P.eyeX + R(9,17); P.blushY = P.eyeY - R(11,17);
P.armType = pick(['nub','stubby','stubby','long','none']); P.armSize = R(.13,.22); P.armLength = R(.1,.4); P.armX = R(72,95); P.armY = R(-35,-8); P.armAngle = R(10,35);
P.legType = pick(['paws','paws','stubby','smooth','none']); P.legSize = R(.22,.34); P.legLength = P.legType==='stubby' ? R(.12,.35) : R(0,.1); P.legSpread = R(16,30);
P.tailType = pick(['pom','pom','fluffy','none']); P.tailSize = R(.4,.8);
P.furLength = pick([()=>R(.04,.1), ()=>R(.12,.22), ()=>R(.12,.22), ()=>R(.26,.48)])();
P.furWidth = R(.016,.034); P.furDensity = Math.round(R(2200,3800)/50)*50; P.furGravity = R(.4,1.2); P.furStiff = R(.25,.75);
if (chance(.38)){ P.furCurl = R(.35,.95); P.furCurlFreq = R(6,18); } else { P.furCurl = R(0,.08); P.furCurlFreq = R(6,12); }
P.furLean = R(.15,.55); P.furVar = R(.25,.7);
P.furShag = chance(.45) ? R(.3,.95) : R(0,.15);
P.bald = chance(.3) ? R(.25,.9) : 0; P.baldScale = R(.75,1.6);
P.tuft = chance(.45) ? R(.3,1.1) : 0; P.bellyPatch = chance(.5) ? R(.22,.45) : 0;
P.patType = pick(['none','none','none','stripes','camo','leopard','spots','patches']); P.patScale = R(2,4.5); P.patAmt = R(.35,.7);
applyPalette(pick(PALETTES), .03);
if (chance(.6)) contrastFur();
else { const pt = new THREE.Color(P.colBot); P.colBot = '#' + pt.lerp(new THREE.Color(P.colPat), .55).getHexString(); }
applyFaceColor(pick(['comp','comp','comp','white','black']));
if (chance(.5) && lum(P.colFace) >= .22) P.eyeColor = pick(['#1b1b22','#2b1a12','#1f2a4a','#3a1f3d','#183a32','#4a2a1a']);
setName(makeName());
}
let currentName = 'Kinako';
function setName(n){ currentName = n; document.getElementById('name').textContent = n; }
/* ================= UI ================= */
const $ = id => document.getElementById(id);
const panel = $('panel'), bGen = $('bGen');
const ctrls = {};
const fmt = v => Math.abs(v) >= 100 ? Math.round(v) : Math.abs(v) >= 10 ? (+v).toFixed(0) : Math.abs(v) < .1 && v !== 0 ? (+v).toFixed(3) : (+v).toFixed(2);
function setPending(on){ bGen.classList.toggle('pending', on); }
function onParam(k){
if (LIVE.has(k)) syncLive();
else if (LAYOUT.has(k)){ layout(); if (CLEARKEYS.has(k)) setPending(true); }
else setPending(true);
}
for (const sec of SECTIONS){
const det = document.createElement('details'); if (sec.open) det.open = true;
const sum = document.createElement('summary'); sum.textContent = sec.t; det.appendChild(sum);
for (const c of sec.c){
const [k, label, a, b, step] = c;
const row = document.createElement('div'); row.className = 'row';
const lab = document.createElement('label'); lab.textContent = label; if (LIVE.has(k) || LAYOUT.has(k)) lab.className = 'live';
row.appendChild(lab);
if (Array.isArray(a)){
const wrap = document.createElement('div'); wrap.className = 'chips';
const btns = a.map(([v,l])=>{
const bt = document.createElement('button'); bt.className = 'chip'; bt.textContent = l;
bt.onclick = ()=>{ if (b==='preset'){ applyShape(v); refreshUI(); setPending(true); } else if (b==='faceauto'){ applyFaceColor(v); refreshUI(); syncLive(); } else { P[k] = v; ctrls[k].set(v); onParam(k); } };
wrap.appendChild(bt); return [v,bt];
});
row.appendChild(document.createElement('span')); row.appendChild(wrap);
ctrls[k] = {set:v=>btns.forEach(([bv,bt])=>bt.classList.toggle('on', bv===v))};
} else if (a==='color'){
const inp = document.createElement('input'); inp.type = 'color';
inp.oninput = ()=>{ P[k] = inp.value; onParam(k); };
row.appendChild(inp); ctrls[k] = {set:v=>inp.value = v};
} else if (a==='bool'){
const t = document.createElement('button'); t.className = 'tog';
t.onclick = ()=>{ P[k] = !P[k]; ctrls[k].set(P[k]); onParam(k); };
row.appendChild(t); ctrls[k] = {set:v=>t.classList.toggle('on', !!v)};
} else if (a==='palettes'){
const sw = document.createElement('div'); sw.className = 'sw';
PALETTES.forEach(pl=>{
const s = document.createElement('span'); s.title = pl.n;
s.style.background = `linear-gradient(135deg, ${pl.colTop} 0 45%, ${pl.colPat} 45% 70%, ${pl.colFace} 70%)`;
s.onclick = ()=>{ applyPalette(pl); refreshUI(); syncLive(); };
sw.appendChild(s);
});
row.appendChild(document.createElement('span')); row.appendChild(sw);
} else {
const val = document.createElement('span'); val.className = 'val';
const inp = document.createElement('input'); inp.type = 'range'; inp.min = a; inp.max = b; inp.step = step;
inp.oninput = ()=>{ P[k] = parseFloat(inp.value); val.textContent = fmt(P[k]); onParam(k); };
row.appendChild(val); row.appendChild(inp);
ctrls[k] = {set:v=>{ inp.value = v; val.textContent = fmt(v); }};
}
det.appendChild(row);
}
panel.appendChild(det);
}
const legend = document.createElement('div'); legend.className = 'legend';
legend.innerHTML = 'Grün markierte Regler wirken sofort. Alle anderen Änderungen werden mit <b>Generieren</b> übernommen.';
panel.appendChild(legend);
function refreshUI(){ for (const k in ctrls) ctrls[k].set(P[k]); }
bGen.onclick = ()=> build();
$('bRnd').onclick = ()=>{ randomize(); refreshUI(); build(); };
$('name').onclick = ()=> setName(makeName());
function download(url, name){ const a = document.createElement('a'); a.href = url; a.download = name; a.click(); }
$('bPng').onclick = ()=>{ renderer.render(scene,camera); download(renderer.domElement.toDataURL('image/png'), currentName.toLowerCase()+'.png'); };
$('bSave').onclick = ()=>{ const u = URL.createObjectURL(new Blob([JSON.stringify({name:currentName, ...P},null,2)],{type:'application/json'})); download(u, currentName.toLowerCase()+'.json'); setTimeout(()=>URL.revokeObjectURL(u),1000); };
$('bLoad').onclick = ()=> $('loadInput').click();
$('loadInput').onchange = e=>{
const f = e.target.files[0]; if (!f) return;
f.text().then(t=>{ try { const d = JSON.parse(t); for (const k in DEF) if (k in d) P[k] = d[k]; if (d.name) setName(d.name); refreshUI(); build(); } catch(err){ alert('Datei konnte nicht gelesen werden.'); } });
e.target.value = '';
};
let panelVisible = innerWidth > 760;
if (!panelVisible) panel.classList.add('hidden');
$('togglePanel').onclick = ()=>{ panelVisible = !panelVisible; panel.classList.toggle('hidden', !panelVisible); resize(); };
/* ================= INTERAKTION ================= */
const DRAG = {
eye:{yaw:'eyeX', pitch:'eyeY', regen:true}, mouth:{pitch:'mouthY', regen:true}, nose:{pitch:'noseY', regen:true},
blush:{yaw:'blushX', pitch:'blushY'}, arm:{yaw:'armX', pitch:'armY'}, leg:{yaw:'legSpread'}
};
function setNdc(e){ const r = canvas.getBoundingClientRect(); ndc.set((e.clientX-r.left)/r.width*2-1, -(e.clientY-r.top)/r.height*2+1); }
function pickHit(){ if (!rig) return null; ray.setFromCamera(ndc, camera); return ray.intersectObjects(rig.pickables, false)[0] || null; }
function onDown(e){
setNdc(e);
const hit = pickHit();
down = {x:e.clientX, y:e.clientY, hit};
let k = hit && hit.object.userData.drag;
if (!k && hit && hit.object === rig.bodyMesh && P.blush > .01){
const local = rig.bodyMesh.worldToLocal(hit.point.clone());
if (U.blushP.value.some(b=>local.distanceTo(new V3(b.x,b.y,b.z)) < b.w*.9)) k = 'blush';
}
if (k){ drag = {key:k}; controls.enabled = false; canvas.setPointerCapture(e.pointerId); canvas.style.cursor = 'grabbing'; }
}
let lastBrush = null, lastBrushT = 0;
canvas.addEventListener('pointermove', e=>{
setNdc(e);
if (drag){ doDrag(); return; }
if (e.buttons) return;
const hit = pickHit();
canvas.style.cursor = hit && hit.object.userData.drag ? 'grab' : (hit ? 'pointer' : 'default');
if (hit){
const now = performance.now();
if (lastBrush && now - lastBrushT < 120){
const dt = Math.max((now - lastBrushT)/1000, 1/240);
const v = hit.point.clone().sub(lastBrush).divideScalar(dt).multiplyScalar(.5);
if (v.length() > 4) v.setLength(4);
U.brushVel.value.lerp(v, .5);
}
U.brush.value.set(hit.point.x, hit.point.y, hit.point.z, .3*P.headSize);
lastBrush = hit.point.clone(); lastBrushT = now;
} else lastBrush = null;
});
canvas.addEventListener('pointerup', e=>{
const moved = down ? Math.hypot(e.clientX-down.x, e.clientY-down.y) : 99;
if (drag){
const F = DRAG[drag.key];
drag = null; controls.enabled = true; canvas.style.cursor = 'grab';
if (moved >= 5 && F.regen) build();
}
if (down && down.hit && moved < 5) boop();
down = null;
});
function doDrag(){
const F = DRAG[drag.key];
ray.setFromCamera(ndc, camera);
const h = ray.intersectObject(rig.bodyMesh, false)[0]; if (!h) return;
const d = invShape(rig.S, rig.bodyMesh.worldToLocal(h.point.clone()));
const yaw = Math.atan2(d.x, d.z)/D2R, pitch = Math.asin(clamp(d.y,-1,1))/D2R;
if (F.yaw){ const r = RANGE[F.yaw]; P[F.yaw] = clamp(Math.abs(yaw), r[0], r[1]); ctrls[F.yaw].set(P[F.yaw]); }
if (F.pitch){ const r = RANGE[F.pitch]; P[F.pitch] = clamp(pitch, r[0], r[1]); ctrls[F.pitch].set(P[F.pitch]); }
layout();
}
/* ================= ANIMATION ================= */
/* ---- sfxr-Synthese (Port des sfxr-Algorithmus von DrPetter / jsfxr) ---- */
const SR = 44100;
function sfxr(p){
const q = Object.assign({wave:0, base:.3, limit:0, ramp:0, dramp:0, vib:0, vibSpeed:0, arpMod:0, arpSpeed:0, duty:0, dutyRamp:0, repeat:0,
phaOff:0, phaRamp:0, lpf:1, lpfRamp:0, lpfRes:0, hpf:0, hpfRamp:0, attack:0, sustain:.3, punch:0, decay:.4}, p);
let fperiod, fmaxperiod, fslide, fdslide, sqDuty, sqSlide, arpMul, arpTime, arpLimit;
function reset(){
fperiod = 100/(q.base*q.base + .001); fmaxperiod = 100/(q.limit*q.limit + .001);
fslide = 1 - Math.pow(q.ramp,3)*.01; fdslide = -Math.pow(q.dramp,3)*.000001;
sqDuty = .5 - q.duty*.5; sqSlide = -q.dutyRamp*.00005;
arpMul = q.arpMod >= 0 ? 1 - q.arpMod*q.arpMod*.9 : 1 + q.arpMod*q.arpMod*10;
arpTime = 0; arpLimit = q.arpSpeed === 1 ? 0 : Math.floor((1 - q.arpSpeed)**2*20000 + 32);
}
reset();
let fltp = 0, fltdp = 0, fltw = Math.pow(q.lpf,3)*.1;
const fltwD = 1 + q.lpfRamp*.0001;
const fltdmp = Math.min(.8, 5/(1 + q.lpfRes*q.lpfRes*20)*(.01 + fltw));
let fltphp = 0, flthp = q.hpf*q.hpf*.1; const flthpD = 1 + q.hpfRamp*.0003;
let vibPhase = 0; const vibSpeed = q.vibSpeed*q.vibSpeed*.01, vibAmp = q.vib*.5;
const envLen = [q.attack*q.attack*1e5, q.sustain*q.sustain*1e5, q.decay*q.decay*1e5].map(v=>Math.max(1, Math.floor(v)));
let envStage = 0, envTime = 0;
let fphase = q.phaOff*q.phaOff*1020*Math.sign(q.phaOff); const fdphase = q.phaRamp*q.phaRamp*Math.sign(q.phaRamp);
const phaser = new Float32Array(1024); let ipp = 0;
const noise = new Float32Array(32).map(()=>Math.random()*2 - 1);
const repLimit = q.repeat ? Math.floor((1 - q.repeat)**2*20000 + 32) : 0; let repTime = 0;
let phase = 0;
const out = new Float32Array(envLen[0] + envLen[1] + envLen[2]);
for (let i=0;i<out.length;i++){
if (repLimit && ++repTime >= repLimit){ repTime = 0; reset(); }
arpTime++; if (arpLimit && arpTime >= arpLimit){ arpLimit = 0; fperiod *= arpMul; }
fslide += fdslide; fperiod *= fslide;
if (fperiod > fmaxperiod){ fperiod = fmaxperiod; if (q.limit > 0) break; }
let rf = fperiod; if (vibAmp > 0){ vibPhase += vibSpeed; rf = fperiod*(1 + Math.sin(vibPhase)*vibAmp); }
const period = Math.max(8, Math.floor(rf));
sqDuty = clamp(sqDuty + sqSlide, 0, .5);
envTime++; if (envTime > envLen[envStage]){ envTime = 0; envStage++; if (envStage > 2) break; }
const ev = envStage === 0 ? envTime/envLen[0] : envStage === 1 ? 1 + (1 - envTime/envLen[1])*2*q.punch : 1 - envTime/envLen[2];
fphase += fdphase; const iphase = Math.min(1023, Math.abs(Math.floor(fphase)));
if (flthpD !== 1) flthp = clamp(flthp*flthpD, .00001, .1);
let ss = 0;
for (let si=0;si<8;si++){
phase++;
if (phase >= period){ phase %= period; if (q.wave === 3) for (let k=0;k<32;k++) noise[k] = Math.random()*2 - 1; }
const fp = phase/period; let s;
if (q.wave === 0) s = fp < sqDuty ? .5 : -.5;
else if (q.wave === 1) s = 1 - fp*2;
else if (q.wave === 2) s = Math.sin(fp*2*Math.PI);
else s = noise[Math.floor(phase*32/period)];
const pp = fltp; fltw = clamp(fltw*fltwD, 0, .1);
if (q.lpf !== 1){ fltdp += (s - fltp)*fltw; fltdp -= fltdp*fltdmp; } else { fltp = s; fltdp = 0; }
fltp += fltdp;
fltphp += fltp - pp; fltphp -= fltphp*flthp; s = fltphp;
phaser[ipp & 1023] = s; s += phaser[(ipp - iphase + 1024) & 1023]; ipp++;
ss += s*ev;
}
out[i] = ss/8;
}
return out;
}
/* ---- Audiosprache ---- */
let actx = null, voiceGain = null;
function audio(){
if (!actx){ actx = new (window.AudioContext || window.webkitAudioContext)(); voiceGain = actx.createGain(); voiceGain.gain.value = P.volume; voiceGain.connect(actx.destination); }
if (actx.state === 'suspended') actx.resume();
return actx;
}
const secP = s => Math.sqrt(Math.max(0,s)*SR/1e5);
function slideFor(ratio, d){ const f = Math.pow(ratio, -1/(d*SR)); return clamp(Math.cbrt((1 - f)/.01), -1, 1); }
const sizeF = ()=> clamp(P.headSize, .4, 2);
function voice(){
// kleine Kreaturen: höher und schneller, große: tiefer und langsamer
return {wave:{square:0, saw:1, sine:2, mix:-1}[P.voiceWave] ?? 0, pitch:P.voicePitch*Math.pow(sizeF(), -.75), speed:P.voiceSpeed*Math.pow(sizeF(), -.45),
duty:.05 + P.voiceRough*.45, lpf:.3 + P.voiceRough*.7, vib:P.voiceVib};
}
function syl(V, o, i){
const d = (o.dur || .1)/V.speed;
let wave = o.wave ?? V.wave; if (wave === -1) wave = i%2 ? 2 : 0;
const arr = sfxr({wave, base:clamp(V.pitch*(o.pitch || 1), .04, .98), ramp:slideFor(o.slide || 1, d), duty:o.duty ?? V.duty, dutyRamp:o.dutyRamp || 0,
vib:V.vib + (o.vib || 0), vibSpeed:o.vibSpeed ?? .45, attack:secP(d*(o.att ?? .08)), sustain:secP(d*.55), decay:secP(d*.37),
punch:o.punch || 0, lpf:clamp(V.lpf*(o.lpf || 1), .05, 1), lpfRes:o.lpfRes || 0, hpf:o.hpf || 0,
repeat:o.repeat || 0, arpMod:o.arpMod || 0, arpSpeed:o.arpSpeed || 0, phaOff:o.phaOff || 0, phaRamp:o.phaRamp || 0});
let pk = 1e-6; for (let k=0;k<arr.length;k++) pk = Math.max(pk, Math.abs(arr[k]));
const gn = (o.gain ?? 1)/pk; for (let k=0;k<arr.length;k++) arr[k] *= gn;
return arr;
}
// "Dialekt": pro Kreatur feste Klangeigenheiten, Satzvorlagen und Animationsvarianten (aus dem Seed)
function dialect(){
const r = mulberry(((P.seed >>> 0) ^ 0x5bd1e995) >>> 0);
return {
syl:.05 + r()*.09, gap:.01 + r()*.06,
arp:r() < .3 ? (r() < .5 ? -1 : 1)*(.2 + r()*.3) : 0, arpSpeed:.45 + r()*.35,
sweep:r() < .3 ? (r() - .5)*.6 : 0, phaser:r() < .25 ? .1 + r()*.25 : 0, res:r()*.6,
count:.7 + r()*.9, bias:(r() - .5)*.3,
tmpl:[r(),r(),r(),r(),r()], anim:[r(),r(),r(),r(),r()], amp:.7 + r()*.7, spd:.7 + r()*.7
};
}
function plan(type){
const D = dialect(), idx = REACTS.indexOf(type);
const r = mulberry(((P.seed >>> 0) ^ Math.imul(idx + 3, 2654435761)) >>> 0);
const t = Math.floor(D.tmpl[idx]*4);
const j = ()=> 1 + (Math.random() - .5)*.08;
const sd = D.syl, gp = D.gap, cnt = n => Math.max(1, Math.round(n*D.count));
const S = [];
const X = o => S.push(Object.assign({arpMod:D.arp, arpSpeed:D.arpSpeed, dutyRamp:D.sweep, phaOff:D.phaser, lpfRes:D.res}, o));
if (type === 'attract'){
if (t === 0){ const n = cnt(2 + r()*2); let p = .95 + r()*.1; const st = .07 + r()*.12;
for (let i=0;i<n;i++){ X({pitch:p*j(), dur:sd*(.8 + r()*.5), slide:1.1 + r()*.15 + D.bias, gap:gp + .02, punch:.2}); p *= 1 + st; }
X({pitch:p*j(), dur:sd*2.4, slide:1.3 + r()*.2, vib:.15, punch:.4}); }
else if (t === 1){ X({pitch:(.9 + r()*.15)*j(), dur:sd*4, slide:1.5 + r()*.4, repeat:.45 + r()*.25, gap:gp}); X({pitch:1.5*j(), dur:sd*.9, slide:1.1, punch:.5}); }
else if (t === 2){ X({wave:2, pitch:.85*j(), dur:sd*3, slide:1.05, vib:.12, att:.25, gap:gp*2}); X({wave:2, pitch:1.15*j(), dur:sd*3.6, slide:1.12, vib:.18, att:.2}); }
else { const n = cnt(4 + r()*3); for (let i=0;i<n;i++) X({pitch:(i%2 ? 1.25 : 1.05)*(1 + r()*.08)*j(), dur:sd*.55, slide:i%2 ? .9 : 1.1, gap:gp*.6, punch:.3}); }
} else if (type === 'repel'){
if (t === 0){ X({wave:3, pitch:2.3, dur:.2, hpf:.25, lpf:3, gain:.5, gap:.03}); X({pitch:.55*j(), dur:sd*2, slide:.55, punch:.6, gap:.04}); if (r() < .6) X({pitch:.47*j(), dur:sd*1.6, slide:.6, punch:.5}); }
else if (t === 1){ const n = cnt(2 + r()); for (let i=0;i<n;i++) X({wave:1, pitch:(.5 + r()*.1)*j(), dur:sd*1.3, slide:.75, punch:.8, gap:gp + .06, lpf:1.2}); }
else if (t === 2){ X({wave:0, pitch:.38*j(), dur:sd*6, slide:.9, vib:.4, vibSpeed:.85, duty:.7, dutyRamp:.2, lpfRes:.6}); }
else { X({wave:3, pitch:1.8, dur:sd*1.5, hpf:.2, gain:.6}); X({pitch:1.6*j(), dur:sd*2.5, slide:.45, punch:.4}); }
} else if (type === 'question'){
if (t === 0){ const n = cnt(1 + r()*2), p = .95 + r()*.1;
for (let i=0;i<n;i++) X({pitch:p*(1 + (r() - .5)*.12)*j(), dur:sd*1.1, gap:gp + .03});
X({pitch:p*j(), dur:sd*2.6, slide:1.7 + r()*.3, vib:.05}); }
else if (t === 1){ X({wave:2, pitch:.7*j(), dur:sd*3, vib:.06, gap:gp + .05, att:.2}); X({pitch:1.0*j(), dur:sd*1.4, slide:2.0}); }
else if (t === 2){ X({pitch:.9*j(), dur:sd*3.5, slide:1.9, arpMod:-.35, arpSpeed:.6}); }
else { for (let k=0;k<2;k++) X({pitch:(.85 + k*.15)*j(), dur:sd*1.6, slide:1.5, gap:gp + .08}); }
} else if (type === 'idle'){
if (t === 0){ const n = cnt(2 + r()*2); for (let i=0;i<n;i++) X({pitch:(.82 + r()*.25)*j(), dur:sd*(.8 + r()*.6), slide:.9 + r()*.25, lpf:.6, gain:.55, gap:gp + .04 + r()*.05}); }
else if (t === 1){ X({wave:2, pitch:.8*j(), dur:sd*7, slide:.97, vib:.1, vibSpeed:.3, att:.3, gain:.5}); }
else if (t === 2){ const n = cnt(3 + r()*3); for (let i=0;i<n;i++) X({wave:r() < .5 ? 3 : 0, pitch:(1.6 + r()*.6)*j(), dur:sd*.3, gain:.45, gap:gp + .04 + r()*.05, hpf:.15}); }
else { const n = cnt(3 + r()*2); for (let i=0;i<n;i++) X({pitch:(.75 + r()*.45)*j(), dur:sd*(.6 + r()*.8), slide:r() < .5 ? .8 : 1.25, gain:.6, gap:gp + .02}); }
} else {
if (t === 0){ X({wave:2, pitch:.72*j(), dur:.6, slide:.85, vib:.3, vibSpeed:.22, att:.35, gain:.5, gap:.28}); X({wave:2, pitch:.62*j(), dur:.8, slide:.8, vib:.3, vibSpeed:.2, att:.4, gain:.42}); }
else if (t === 1){ X({wave:3, pitch:.6, dur:.9, slide:1.4, lpf:.25, att:.5, gain:.45, gap:.15, arpMod:0}); X({wave:3, pitch:.7, dur:1.1, slide:.6, lpf:.2, att:.15, gain:.4, arpMod:0}); }
else if (t === 2){ const n = 3 + Math.floor(r()*2); let p = .85; for (let i=0;i<n;i++){ X({wave:2, pitch:p*j(), dur:.38, vib:.15, vibSpeed:.25, att:.3, gain:.45, gap:.08, arpMod:0}); p *= .89 - r()*.04; } }
else { X({wave:0, pitch:.22*j(), dur:1.6, repeat:.55, slide:1.15, duty:.6, lpf:.5, gain:.4, arpMod:0}); }
}
return S;
}
function speak(type){
const ctx = audio(), V = voice();
const parts = plan(type).map((o,i)=>({a:syl(V,o,i), gap:Math.floor((o.gap || 0)/V.speed*SR)}));
let len = Math.floor(.05*SR); for (const p of parts) len += p.a.length + p.gap;
const buf = new Float32Array(len); let off = 0;
for (const p of parts){ buf.set(p.a, off); off += p.a.length + p.gap; }
const win = Math.floor(SR/60), env = new Float32Array(Math.ceil(len/win)); let emax = 1e-6;
for (let i=0;i<env.length;i++){ let s = 0; const e = Math.min(len, (i+1)*win); for (let k=i*win;k<e;k++) s += buf[k]*buf[k]; env[i] = Math.sqrt(s/win); emax = Math.max(emax, env[i]); }
for (let i=0;i<env.length;i++) env[i] = Math.min(1, env[i]/emax*1.15);
const ab = ctx.createBuffer(1, len, SR), ch = ab.getChannelData(0);
for (let i=0;i<len;i++) ch[i] = Math.tanh(buf[i]*1.3)*.75;
const src = ctx.createBufferSource(); src.buffer = ab; voiceGain.gain.value = P.volume; src.connect(voiceGain);
const start = ctx.currentTime + .02; src.start(start);
anim.voice = {env, start};
return len/SR;
}
function react(type){
const D = dialect(), idx = REACTS.indexOf(type);
const dur = speak(type);
anim.act = {type, t:0, dur:Math.max(dur, type === 'dream' ? 2.8 : 1.0), side:chance(.5) ? -1 : 1,
v:Math.floor(D.anim[idx]*5), amp:D.amp, spd:D.spd*Math.pow(sizeF(), -.5)};
document.querySelectorAll('#react button').forEach(b=>b.classList.toggle('on', b.dataset.r === type));
}
/* ---- Animation: ruhig, nur Atmung + Reaktionen ---- */
const anim = {t:0, blinkT:-1, nextBlink:2, act:null, voice:null, open:0, puff:0, sy:1,
prevYaw:0, prevRoll:0, prevLean:0, prevHopY:0, iPos:new V3(), iVel:new V3()};
function boop(){ react('attract'); }
function update(dt){
const t = (anim.t += dt); U.time.value = t;
if (!rig) return;
let blink = 0;
if (P.blink && t > anim.nextBlink){ anim.blinkT = 0; anim.nextBlink = t + (chance(.15) ? .3 : R(3,7)); }
if (anim.blinkT >= 0){ anim.blinkT += dt; const u = anim.blinkT/.16; if (u >= 1) anim.blinkT = -1; else blink = Math.sin(u*Math.PI); }
let lean = 0, yaw = 0, roll = 0, sq = 1, hopY = 0, zOff = 0, eyeOpen = 1, eyeS = 1, armL = 0, armR = 0, wave = 0, puff = 0, wag = 0, breathMul = 1, mouthMul = 1;
const A = anim.act;
if (A){
A.t += dt; const at = A.t*A.spd, am = A.amp, v = A.v, sd = A.side;
const w = smoothstep(0, .18, A.t)*(1 - smoothstep(A.dur, A.dur + .45, A.t));
if (A.t > A.dur + .5){ anim.act = null; document.querySelectorAll('#react button').forEach(b=>b.classList.remove('on')); }
const hop = (t0, len, h)=> (at > t0 && at < t0 + len) ? Math.sin((at - t0)/len*Math.PI)*h : 0;
const once = (len)=> Math.sin(clamp(at/len, 0, 1)*Math.PI);
if (A.type === 'attract'){
eyeOpen = 1 - .62*w; wag = w;
if (v === 0){ lean = .15*w*am; hopY = hop(0, .55, .12*am); armL = armR = .95*w; wave = w; }
else if (v === 1){ yaw = smoothstep(0, .9, at)*Math.PI*2*sd; armL = armR = .6*w; hopY = hop(.1, .7, .08*am); }
else if (v === 2){ hopY = hop(0, .33, .07*am) + hop(.38, .33, .07*am) + hop(.76, .33, .07*am); armL = armR = .5*w; wave = w; }
else if (v === 3){ roll = Math.sin(at*9)*.14*w*am; armL = armR = .7*w; wave = w; }
else { sq = 1 + .12*am*once(.7); lean = .08*w; armL = armR = 1.1*w; }
} else if (A.type === 'repel'){
puff = w;
if (v === 0){ lean = -.2*w*am; yaw = Math.sin(at*30)*.12*w*Math.exp(-at*1.8); sq = 1 - .07*w; eyeOpen = 1 - .45*w; armL = armR = -.3*w; }
else if (v === 1){ sq = 1 - .18*w*am; eyeOpen = 1 - .7*w; armL = armR = -.4*w; }
else if (v === 2){ yaw = -sd*1.9*w; lean = -.06*w; eyeOpen = 1 - .3*w; }
else if (v === 3){ const tr = Math.sin(at*55)*.025*w*am; roll = tr; yaw = tr*.6; sq = 1 - .05*w; eyeS = 1 + .12*w; }
else { zOff = -.35*w*am*P.headSize; hopY = hop(0, .3, .08); lean = -.15*w; eyeOpen = 1 - .5*w; }
} else if (A.type === 'question'){
if (v === 0){ roll = .26*w*sd*am; lean = .05*w; eyeS = 1 + .15*w; if (sd > 0) armR = .55*w; else armL = .55*w; }
else if (v === 1){ lean = .22*w*am; eyeS = 1 + .22*w; zOff = .12*w*P.headSize; }
else if (v === 2){ roll = Math.sin(at*3.2)*.22*w*am; eyeS = 1 + .1*w; }
else if (v === 3){ hopY = hop(0, .4, .07*am); roll = .2*w*sd; armL = armR = .35*w; }
else { yaw = .35*w*sd*am; eyeS = 1 + .25*w; lean = .05*w; }
} else if (A.type === 'idle'){
if (v === 0){ yaw = Math.sin(at*2.4)*.2*w*am; wag = .4*w; }
else if (v === 1){ sq = 1 + .1*once(.9); armL = armR = .8*once(.9); eyeOpen = 1 - .5*w; }
else if (v === 2){ roll = Math.sin(at*2)*.1*w*am; lean = Math.sin(at*1.3)*.05*w; }
else if (v === 3){ armL = (Math.sin(at*7)*.5 + .3)*w; armR = (Math.sin(at*7 + Math.PI)*.5 + .3)*w; }
else { hopY = hop(.1, .35, .05*am); wag = .6*w; }
} else if (A.type === 'dream'){
eyeOpen = 1 - .95*w; mouthMul = .35; breathMul = 1 + 1.6*w;
if (v === 0){ roll = Math.sin(at*1.1)*.07*w; lean = .12*w; }
else if (v === 1){ lean = .28*w; sq = 1 - .05*w; }
else if (v === 2){ hopY = .12*w*(1 + Math.sin(at*1.2)*.3)*am; yaw = Math.sin(at*.5)*.3*w; }
else if (v === 3){ roll = -.35*w*sd; lean = .05*w; }
else { sq = 1 + Math.sin(at*2.2)*.06*w; roll = Math.sin(at*.9)*.05*w; }
}
}
anim.puff += (puff - anim.puff)*(1 - Math.exp(-dt*6));
U.stiff.value = P.furStiff*(1 + 2.2*anim.puff);
U.gravity.value = P.furGravity*(1 - .85*anim.puff);
anim.sy += (sq - anim.sy)*(1 - Math.exp(-dt*18));
const root = rig.root;
const breath = 1 + Math.sin(t*1.8*Math.pow(sizeF(), -.5))*.016*P.breath*breathMul;
const sy = anim.sy*breath, sxz = 1/Math.sqrt(sy);
root.scale.set(sxz, sy, sxz);
root.position.y = rig.baseY + hopY*P.headSize;
root.position.z = zOff;
rig.pivot.rotation.set(lean, yaw, roll);
const sdt = Math.max(dt, 1e-3);
const yawVel = (yaw - anim.prevYaw)/sdt; anim.prevYaw = yaw;
const rollVel = (roll - anim.prevRoll)/sdt; anim.prevRoll = roll;
const leanVel = (lean - anim.prevLean)/sdt; anim.prevLean = lean;
const hopVel = (hopY - anim.prevHopY)/sdt; anim.prevHopY = hopY;
for (const e of rig.eyes) e.blink.scale.set(eyeS, Math.max(.06, (1 - blink*.92)*eyeOpen*eyeS), eyeS);
for (const a of rig.arms){
const lift = a.side > 0 ? armR : armL;
a.swing.rotation.set(lift*.15, 0, a.side*(P.armAngle*D2R + lift*1.6 + wave*Math.sin(t*14 + a.side)*.2));
}
for (const f of rig.feet) f.lift.rotation.x = hopY > .01 ? -.22*Math.min(1, hopY*8) : 0;
if (rig.tail) rig.tail.wag.rotation.y = Math.sin(t*(2 + 10*wag))*(.04 + .3*wag);
let open = 0;
if (anim.voice && actx){
const i = Math.floor((actx.currentTime - anim.voice.start)*60);
if (i >= anim.voice.env.length) anim.voice = null; else if (i >= 0) open = anim.voice.env[i];
}
anim.open += (open*mouthMul - anim.open)*(1 - Math.exp(-dt*25));
if (rig.mouth) rig.mouth.userData.setOpen(anim.open);
const target = new V3(-yawVel*.5 + rollVel*.4, -hopVel*.9, -leanVel*.35);
anim.iVel.addScaledVector(target.sub(anim.iPos).multiplyScalar(70).sub(anim.iVel.clone().multiplyScalar(6)), dt);
anim.iPos.addScaledVector(anim.iVel, dt);
if (anim.iPos.length() > 2) anim.iPos.setLength(2);
U.inertia.value.copy(anim.iPos);
U.brushVel.value.multiplyScalar(Math.exp(-dt*2.2));
const s = rig.shadowS*(1 - hopY*.9);
shadow.scale.set(s, s, 1);
shadow.material.opacity = clamp(1 - hopY*1.6, 0, 1);
}
/* ================= LOOP ================= */
function resize(){
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w/h;
const narrow = w < 760;
const pw = !narrow && panelVisible ? 332 : 0;
const ph = narrow && panelVisible ? h*.46 : 0;
camera.setViewOffset(w, h, pw*.5, ph*.5, w, h);
document.getElementById('react').style.left = ((w - pw)/2) + 'px';
camera.updateProjectionMatrix();
U.px.value = 2*Math.tan(camera.fov*D2R/2)/(h*renderer.getPixelRatio());
}
addEventListener('resize', resize);
const REACTS = ['attract','repel','question','idle','dream'];
document.querySelectorAll('#react button').forEach(b=> b.onclick = ()=> react(b.dataset.r));
addEventListener('keydown', e=>{
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
if (e.code === 'Enter'){ e.preventDefault(); build(); }
const n = parseInt(e.key); if (n >= 1 && n <= 5) react(REACTS[n-1]);
});
resize();
refreshUI();
build();
controls.target.y = targetY; camera.position.y += targetY - .9;
let camMoving = false;
controls.addEventListener('start', ()=>{ camMoving = true; });
controls.addEventListener('end', ()=>{ camMoving = false; targetDist = camera.position.distanceTo(controls.target); });
const clock = new THREE.Clock();
renderer.setAnimationLoop(()=>{
const dt = Math.min(clock.getDelta(), 1/20);
const dy = (targetY - controls.target.y)*Math.min(1, dt*4);
controls.target.y += dy; camera.position.y += dy;
if (!drag && !camMoving){
const off = camera.position.clone().sub(controls.target), dist = off.length();
const nd = dist + (targetDist - dist)*Math.min(1, dt*2.5);
if (Math.abs(nd - dist) > .002) camera.position.copy(controls.target).addScaledVector(off.normalize(), nd);
}
update(dt);
controls.update();
renderer.render(scene, camera);
});
</script>
</body>
</html>