
혹시 "왁뿌볼" 이라고 아시나요? 왁뿌볼은 왁스를 부시면서 가지고 노는 말랑말랑한 장난감입니다. 슬라임과 비슷한 류의 손으로 가지고 노는 장난감이죠. 이게 요새 SNS 유행을 타면서 장난감 시장에 손님이 늘어났을 정도로 젊은이들은 왁뿌볼에 진심입니다.
왁뿌볼이 유행인 이유는 뭘까요? 바로 왁스가 부서지며 나는 크런치한 소리가 쾌감과 동시에 심리적 안정감을 주기 때문인데요, 왁스를 부시는 건 1번이지만 그 안에 말랑말랑한 재료 덕에 손으로 주물럭거리며 노는 것이, 모래 놀이처럼 참 재밌습니다.
저도! 왁뿌볼을 가지고 싶었는데요? 아트박스에서 왁뿌볼 샀다가 왁스가 다 녹아있거나 너무 얇아서 그냥 말랑이 산 사람이 되버렸습니다. 왁뿌볼의 단점이 왁스 부시는 행위가 좋은건데 1회성이라는 거에요! 그래서... 실패없이 왁뿌볼을 가지고 싶었던 욕망을 담아 왁뿌볼 온라인을 만들어보려고 합니다..
✅ 추천 1순위: 웹사이트 (Vanilla JS + Three.js + Tone.js)
| 항목 | 기술 |
|---|---|
| 3D 렌더링 | Three.js |
| 사운드 생성 | Tone.js (ASMR 사운드 프로그래밍) |
| 변형 효과 | Three.js ShapeGeometry + MorphTargets |


왁스 누르면 왁스 부서지면서 소리나게 만들었다 ㅎㅎ
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>나도 왁뿌볼 🫧</title>
<link href="https://fonts.googleapis.com/css2?family=Gaegu:wght@700&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#ede8f5;font-family:'Noto Sans KR',sans-serif;min-height:100vh;display:flex;flex-direction:column;align-items:center;overflow-x:hidden;user-select:none;-webkit-user-select:none}
.bg{position:fixed;inset:0;z-index:0;pointer-events:none;background-image:radial-gradient(circle,rgba(180,160,220,.18)1.5px,transparent 1.5px);background-size:28px 28px}
header{position:relative;z-index:10;width:100%;max-width:820px;padding:1.4rem 2rem .3rem;display:flex;align-items:baseline;gap:.7rem}
.logo{font-family:'Gaegu',cursive;font-size:2.2rem;background:linear-gradient(135deg,#c050e8,#f060b0,#f0a030);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.tag{font-size:.74rem;color:#9080b0}
main{position:relative;z-index:10;width:100%;max-width:820px;padding:0 1.5rem 2rem;display:flex;flex-direction:column;align-items:center;gap:1.1rem}
#stage{position:relative;touch-action:none;flex-shrink:0;cursor:none}
#stage canvas{display:block}
#cur{position:fixed;pointer-events:none;z-index:9999;font-size:2.5rem;line-height:1;transform:translate(-50%,-108%) rotate(12deg);transition:transform .07s;display:none}
#cur.p1{transform:translate(-50%,-65%) rotate(8deg) scale(1.08)}
#cur.p2{transform:translate(-50%,-46%) rotate(4deg) scale(1.22);font-size:2.8rem}
.pop{position:absolute;pointer-events:none;font-family:'Gaegu',cursive;font-weight:700;z-index:20;animation:pop .85s ease-out forwards;white-space:nowrap}
@keyframes pop{0%{opacity:1;transform:translate(-50%,-50%) scale(.3) rotate(-12deg)}22%{opacity:1;transform:translate(-50%,-82%) scale(1.1) rotate(5deg)}100%{opacity:0;transform:translate(-50%,-200%) scale(.6) rotate(-8deg)}}
.pcl{position:absolute;pointer-events:none;border-radius:3px;animation:fly var(--d) ease-out forwards}
@keyframes fly{0%{opacity:.95;transform:translate(0,0) scale(1) rotate(0deg)}100%{opacity:0;transform:translate(var(--tx),var(--ty)) scale(.05) rotate(var(--rot))}}
.stats{display:flex;gap:1.8rem;justify-content:center}
.si{display:flex;flex-direction:column;align-items:center;gap:1px}
.sv{font-size:1.35rem;font-weight:900;color:#2a1858;transition:transform .12s,color .12s}
.sv.bmp{transform:scale(1.35);color:#c030d8}
.sl{font-size:.63rem;color:#9080b0}
#cbo{font-family:'Gaegu',cursive;font-size:1.8rem;color:#d84010;min-height:2rem;text-align:center;opacity:0;transition:opacity .25s}
.hints{display:flex;gap:.7rem;justify-content:center;flex-wrap:wrap}
.chip{background:rgba(180,140,230,.1);border:1px solid rgba(180,140,230,.25);border-radius:100px;padding:.3rem .75rem;font-size:.68rem;color:#7060a0}
.chip b{color:#2a1858}
.rwrap{width:190px;background:rgba(180,140,230,.14);border-radius:2px;height:3px;overflow:hidden}
#rb{width:0%;height:3px;background:linear-gradient(90deg,#b040e8,#f050a0);border-radius:2px;transition:width .08s linear}
.pal{width:100%;background:rgba(255,255,255,.72);border:1px solid rgba(180,140,230,.18);border-radius:16px;padding:1rem 1.4rem;backdrop-filter:blur(6px)}
.plbl{font-size:.66rem;color:#9080b0;letter-spacing:.1em;text-transform:uppercase;margin-bottom:.7rem}
.pgrid{display:flex;flex-wrap:wrap;gap:9px}
.sw{width:46px;height:46px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:transform .13s,border-color .13s,box-shadow .13s;position:relative;flex-shrink:0}
.sw:hover{transform:scale(1.1)}
.sw.on{border-color:#6020a8;box-shadow:0 0 0 2px rgba(150,90,220,.4),0 4px 14px rgba(0,0,0,.15);transform:scale(1.14)}
.swnm{position:absolute;bottom:-17px;left:50%;transform:translateX(-50%);font-size:8px;color:#9080b0;white-space:nowrap;pointer-events:none}
</style>
</head>
<body>
<div class="bg"></div>
<div id="cur">☝️</div>
<header><div class="logo">나도 왁뿌볼</div><div class="tag">🫧 ASMR 온라인</div></header>
<main>
<div class="stats">
<div class="si"><div class="sv" id="sT">0</div><div class="sl">총 클릭</div></div>
<div class="si"><div class="sv" id="sC">0</div><div class="sl">최고 콤보</div></div>
<div class="si"><div class="sv" id="sB">0</div><div class="sl">롱프레스</div></div>
</div>
<div id="cbo"></div>
<div id="stage"><canvas id="cv"></canvas></div>
<div class="rwrap"><div id="rb"></div></div>
<div class="hints">
<div class="chip">👆 <b>클릭</b> — 뽀각</div>
<div class="chip">👊 <b>길게 누르기</b> — 뽀각뽀각!</div>
<div class="chip">🔁 <b>같은 곳 재클릭</b> — 더 잘게!</div>
</div>
<div class="pal">
<div class="plbl">🎨 색상 선택 (왼쪽=왁스 / 오른쪽=내용물)</div>
<div class="pgrid" id="pg"></div>
</div>
</main>
<script>
// ═══════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════
const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
const lerp = (a,b,t) => a+(b-a)*t;
// ═══════════════════════════════════════════
// CANVAS
// ═══════════════════════════════════════════
const SZ = Math.min(window.innerWidth-40, 430);
const CV = document.getElementById('cv');
CV.width = CV.height = SZ*2;
CV.style.width = CV.style.height = SZ+'px';
document.getElementById('stage').style.width =
document.getElementById('stage').style.height = SZ+'px';
const G = CV.getContext('2d');
const W = CV.width, H = CV.height;
const CX = W/2, CY = H/2;
const R = W*0.41;
// ═══════════════════════════════════════════
// PALETTE
// ═══════════════════════════════════════════
const PAL = [
{name:'딸기민트', wax:['#fce8f0','#f4b8d0','#e07898'], clay:'#c01858', clay2:'#ff5088'},
{name:'블루스카이', wax:['#dceeff','#a8ccf4','#6898d8'], clay:'#0848c0', clay2:'#3888ff'},
{name:'민트크림', wax:['#c8f8e8','#88ddb0','#38a870'], clay:'#087848', clay2:'#30c880'},
{name:'레몬피치', wax:['#fff8c0','#f8e068','#d8a020'], clay:'#b04808', clay2:'#f08020'},
{name:'라벤더', wax:['#eedeff','#c8a0f0','#9060c8'], clay:'#5010a8', clay2:'#a050f0'},
{name:'코랄', wax:['#ffe0d0','#f8a880','#e06030'], clay:'#a01808', clay2:'#f05030'},
{name:'수박', wax:['#d8f8c0','#a0e878','#58b830'], clay:'#a80020', clay2:'#f02848'},
{name:'포도', wax:['#ecdcff','#c8a0e8','#9860c0'], clay:'#380878', clay2:'#8828c8'},
{name:'오션', wax:['#b8eeff','#68ccec','#1898c0'], clay:'#004870', clay2:'#0098c0'},
{name:'로즈', wax:['#ffe8e0','#f8c0a8','#e07858'], clay:'#880020', clay2:'#d03060'},
];
let COL = PAL[0];
// ═══════════════════════════════════════════
// POLYGON CLIPPING (Sutherland-Hodgman)
// ═══════════════════════════════════════════
function clipPolyHalf(poly, ax, ay, bx, by, side) {
// keep points on 'side' of line a→b (side=+1 or -1)
const nx = -(by-ay), ny = bx-ax;
const inside = p => side*((p.x-ax)*nx+(p.y-ay)*ny) >= -1e-9;
const intersect = (p1,p2) => {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const d1=(p1.x-ax)*nx+(p1.y-ay)*ny;
const d2=(p2.x-ax)*nx+(p2.y-ay)*ny;
const t=clamp(d1/(d1-d2),0,1);
return {x:p1.x+t*dx, y:p1.y+t*dy};
};
const out=[];
for(let i=0;i<poly.length;i++){
const c=poly[i], p=poly[(i+poly.length-1)%poly.length];
const ci=inside(c), pi=inside(p);
if(ci){if(!pi)out.push(intersect(p,c));out.push(c);}
else if(pi)out.push(intersect(p,c));
}
return out.length>=3?out:null;
}
function clipPolyCircle(poly, cx, cy, r) {
// Keep parts of poly inside circle
const inside = p => Math.hypot(p.x-cx,p.y-cy)<=r+.5;
const intersectLC = (p1,p2) => {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const fx=p1.x-cx, fy=p1.y-cy;
const a=dx*dx+dy*dy, b=2*(fx*dx+fy*dy), c=fx*fx+fy*fy-r*r;
const disc=b*b-4*a*c;
if(disc<0)return[];
const sq=Math.sqrt(disc);
return [(-b-sq)/(2*a),(-b+sq)/(2*a)].filter(t=>t>1e-6&&t<1-1e-6)
.map(t=>({x:p1.x+t*dx,y:p1.y+t*dy}));
};
const out=[];
for(let i=0;i<poly.length;i++){
const c=poly[i], p=poly[(i+poly.length-1)%poly.length];
const ci=inside(c), pi=inside(p);
const its=intersectLC(p,c);
if(pi&&!ci){if(its.length)out.push(its[its.length-1]);}
else if(!pi&&ci){if(its.length)out.push(its[0]);}
if(ci)out.push(c);
}
return out.length>=3?out:null;
}
// Centroid of polygon
function centroid(poly){
let x=0,y=0;
for(const p of poly){x+=p.x;y+=p.y;}
return {x:x/poly.length,y:y/poly.length};
}
// ═══════════════════════════════════════════
// VORONOI WAX TILES (pre-baked on the ball)
// These cover the ENTIRE ball surface.
// On press: tiles in that zone get "lifted" — they stay in place
// but visually separate, showing clay between them.
// ═══════════════════════════════════════════
function buildBallTiles() {
// Sprinkle seeds over ball area with fixed RNG
const rng = (() => {let s=1337;return()=>{s=(s*1664525+1013904223)>>>0;return s/0xffffffff;};})();
const seeds = [];
const gridN = 10;
for(let gy=0;gy<gridN;gy++) for(let gx=0;gx<gridN;gx++){
const x = CX + (gx/(gridN-1)-.5)*2.1*R + (rng()-.5)*R*.35;
const y = CY + (gy/(gridN-1)-.5)*2.1*R + (rng()-.5)*R*.35;
seeds.push({x,y});
}
const tiles = [];
for(let i=0;i<seeds.length;i++){
// Start with ball circle polygon, clip by Voronoi half-planes
let poly = circlePolyPts(CX,CY,R*1.06,72);
for(let j=0;j<seeds.length;j++){
if(i===j)continue;
const mx=(seeds[i].x+seeds[j].x)/2, my=(seeds[i].y+seeds[j].y)/2;
const nx=seeds[j].x-seeds[i].x, ny=seeds[j].y-seeds[i].y;
poly = clipPolyHalfPt(poly, mx, my, nx, ny);
if(!poly||poly.length<3)break;
}
if(!poly||poly.length<3)continue;
poly = clipPolyCircle(poly, CX, CY, R);
if(!poly||poly.length<3)continue;
const c = centroid(poly);
// Only keep tiles whose centroid is inside ball
if(Math.hypot(c.x-CX,c.y-CY)>R*.98)continue;
tiles.push({poly, cx:c.x, cy:c.y});
}
return tiles;
}
function circlePolyPts(cx,cy,r,n){
const pts=[];
for(let i=0;i<n;i++){const a=(Math.PI*2*i)/n;pts.push({x:cx+Math.cos(a)*r,y:cy+Math.sin(a)*r});}
return pts;
}
function clipPolyHalfPt(poly, mx, my, nx, ny) {
if(!poly||poly.length<3)return null;
const inside = p=>(p.x-mx)*nx+(p.y-my)*ny<=1e-9;
const intersect=(p1,p2)=>{
const dx=p2.x-p1.x,dy=p2.y-p1.y;
const d1=(p1.x-mx)*nx+(p1.y-my)*ny;
const d2=(p2.x-mx)*nx+(p2.y-my)*ny;
const t=clamp(d1/(d1-d2),0,1);
return{x:p1.x+t*dx,y:p1.y+t*dy};
};
const out=[];
for(let i=0;i<poly.length;i++){
const c=poly[i],p=poly[(i+poly.length-1)%poly.length];
const ci=inside(c),pi=inside(p);
if(ci){if(!pi)out.push(intersect(p,c));out.push(c);}
else if(pi)out.push(intersect(p,c));
}
return out.length>=3?out:null;
}
// Pre-build tiles once
const ALL_TILES = buildBallTiles();
// ═══════════════════════════════════════════
// PRESS / CRACK ZONES
// Each press defines a zone on the ball.
// Tiles whose centroid falls in the zone become "cracked" —
// they lift slightly outward from their position, revealing clay.
// Re-clicking subdivides those tiles further.
// ═══════════════════════════════════════════
const ST = {
zones:[], // [{sx,sy,r,depth,big,tiles:[{poly,cx,cy,ox,oy,rot}],level}]
restoreTimer:null,restoreInterval:null,restoreProg:0,
pressTimer:null,isLong:false,
combo:0,comboTimer:null,maxCombo:0,total:0,bigs:0,
};
// Build lifted tile data for a zone given a set of polygons
function buildZoneTiles(sx, sy, polys) {
return polys.map(poly => {
const c = centroid(poly);
const dx=c.x-sx, dy=c.y-sy, d=Math.hypot(dx,dy)||1;
// Lift direction: outward from press center
const liftX=dx/d, liftY=dy/d;
// Random slight rotation for realism
const rot = (Math.random()-.5)*.18;
return {poly, cx:c.x, cy:c.y, liftX, liftY, rot};
});
}
// Subdivide a polygon into smaller pieces using random cuts
function subdivide(poly, cuts) {
let pieces = [poly];
for(let c=0;c<cuts;c++){
const next=[];
for(const p of pieces){
const cen=centroid(p);
// random cut angle through a point near centroid + jitter
const angle=Math.random()*Math.PI;
const jx=cen.x+(Math.random()-.5)*30, jy=cen.y+(Math.random()-.5)*30;
const dx=Math.cos(angle), dy=Math.sin(angle);
const h1=clipPolyHalf(p,jx,jy,jx+dx,jy+dy,+1);
const h2=clipPolyHalf(p,jx,jy,jx+dx,jy+dy,-1);
if(h1&&h1.length>=3)next.push(h1);
if(h2&&h2.length>=3)next.push(h2);
}
if(next.length)pieces=next;
}
return pieces;
}
function addZone(sx, sy, big) {
clearInterval(ST.restoreInterval); clearTimeout(ST.restoreTimer);
const zoneR = big ? R*.50 : R*.30;
// Check if clicking inside an existing zone
let bestIdx=-1, bestDist=Infinity;
for(let i=0;i<ST.zones.length;i++){
const z=ST.zones[i];
const d=Math.hypot(z.sx-sx,z.sy-sy);
if(d < z.r*1.1 && d < bestDist){bestDist=d;bestIdx=i;}
}
if(bestIdx !== -1) {
// RE-CLICK: subdivide existing tiles further
const z = ST.zones[bestIdx];
if((z.level||1) < 5) {
const newPolys = [];
for(const t of z.tiles) newPolys.push(...subdivide(t.poly, 1));
z.tiles = buildZoneTiles(z.sx, z.sy, newPolys);
z.depth = Math.min(1.0, z.depth + (big?.25:.15));
z.level = (z.level||1)+1;
}
} else {
// NEW zone: collect ALL_TILES whose centroid is within zoneR of press
const hitPolys = ALL_TILES
.filter(t => Math.hypot(t.cx-sx,t.cy-sy) < zoneR)
.map(t => {
// Clip tile to zone circle so tiles don't stick out beyond zone
const clipped = clipPolyCircle(t.poly, sx, sy, zoneR*1.05);
return clipped||t.poly;
});
if(hitPolys.length===0) return;
const tiles = buildZoneTiles(sx, sy, hitPolys);
ST.zones.push({sx,sy,r:zoneR,depth:big?1.0:.52,big,tiles,level:1});
if(ST.zones.length>8)ST.zones.shift();
}
}
function startRestore(delay){
clearInterval(ST.restoreInterval); clearTimeout(ST.restoreTimer);
document.getElementById('rb').style.width='0%';
ST.restoreProg=0;
ST.restoreTimer=setTimeout(()=>{
const steps=65,ms=1300/steps;
ST.restoreInterval=setInterval(()=>{
ST.restoreProg=Math.min(1,ST.restoreProg+1/steps);
document.getElementById('rb').style.width=(ST.restoreProg*100)+'%';
ST.zones=ST.zones.map(z=>({...z,depth:z.depth*(1-2.1/steps)})).filter(z=>z.depth>.012);
if(ST.restoreProg>=1){
clearInterval(ST.restoreInterval);ST.zones=[];
setTimeout(()=>{document.getElementById('rb').style.width='0%';},260);
}
},ms);
},delay);
}
function doPress(cx,cy,big){
let[sx,sy]=toCV(cx,cy);
const d=Math.hypot(sx-CX,sy-CY);
if(d>R*.88){const f=R*.88/d;sx=CX+(sx-CX)*f;sy=CY+(sy-CY)*f;}
addZone(sx,sy,big);
playCrunch(big);
spawnPcl(cx,cy,big);
spawnPop(cx,cy,big);
updStats(big);
startRestore(big?3800:2700);
}
// ═══════════════════════════════════════════
// DRAW
// ═══════════════════════════════════════════
function draw(){
G.clearRect(0,0,W,H);
// Shadow
const sg=G.createRadialGradient(CX,CY+R*.93,4,CX,CY+R*.93,R*.65);
sg.addColorStop(0,'rgba(0,0,0,.17)');sg.addColorStop(1,'rgba(0,0,0,0)');
G.fillStyle=sg;G.beginPath();G.ellipse(CX,CY+R*.93+10,R*.65,13,0,0,Math.PI*2);G.fill();
G.save();
G.beginPath();G.arc(CX,CY,R,0,Math.PI*2);G.clip();
// ── 1. CLAY base (always visible as the innermost layer) ──
const cg=G.createRadialGradient(CX-R*.18,CY-R*.22,R*.04,CX+R*.08,CY+R*.10,R*1.02);
cg.addColorStop(0,COL.clay2);cg.addColorStop(.5,COL.clay);cg.addColorStop(1,darken(COL.clay,.3));
G.fillStyle=cg;G.fillRect(0,0,W,H);
// ── 2. SMOOTH WAX — full opaque surface ──
// We draw it, then "punch holes" only at cracked zones by NOT drawing
// the wax there. Instead we draw lifted tile pieces.
drawWax();
// ── 3. AO + depth ──
const ao=G.createRadialGradient(CX,CY+R*.12,R*.32,CX,CY,R*1.0);
ao.addColorStop(0,'rgba(0,0,0,0)');ao.addColorStop(1,'rgba(0,0,0,.26)');
G.fillStyle=ao;G.fillRect(0,0,W,H);
// ── 4. Specular ──
const sp=G.createRadialGradient(CX-R*.28,CY-R*.33,0,CX-R*.16,CY-R*.18,R*.44);
sp.addColorStop(0,'rgba(255,255,255,.82)');sp.addColorStop(.28,'rgba(255,255,255,.20)');sp.addColorStop(1,'rgba(255,255,255,0)');
G.fillStyle=sp;G.fillRect(0,0,W,H);
const sp2=G.createRadialGradient(CX+R*.19,CY-R*.26,0,CX+R*.19,CY-R*.26,R*.16);
sp2.addColorStop(0,'rgba(255,255,255,.44)');sp2.addColorStop(1,'rgba(255,255,255,0)');
G.fillStyle=sp2;G.fillRect(0,0,W,H);
G.restore();
// Ball outline
G.save();G.beginPath();G.arc(CX,CY,R,0,Math.PI*2);
G.strokeStyle='rgba(0,0,0,.08)';G.lineWidth=3;G.stroke();G.restore();
drawKnot();
}
function drawWax(){
// Strategy:
// A. Draw full wax surface
// B. For each cracked zone, "erase" that circular area from wax
// C. Draw lifted tile pieces back in (with gaps between them = clay shows)
// A. Full wax
const wg=G.createRadialGradient(CX-R*.22,CY-R*.28,R*.04,CX+R*.10,CY+R*.12,R*1.06);
wg.addColorStop(0,COL.wax[0]);wg.addColorStop(.42,COL.wax[1]);wg.addColorStop(1,COL.wax[2]);
G.fillStyle=wg;G.fillRect(0,0,W,H);
if(ST.zones.length===0)return;
// B. Erase wax in cracked zones — use a compositing trick:
// Draw a dark hole with 'destination-out' only over the wax layer.
// But we're inside the ball clip, so we need an offscreen canvas for this.
// Simpler: just re-draw clay on top of wax in cracked areas, then tiles on top.
for(const z of ST.zones){
if(z.depth<.01)continue;
// Paint clay color in this zone (covers wax)
const clayR=z.r*1.05;
const zg=G.createRadialGradient(z.sx,z.sy,0,z.sx,z.sy,clayR);
zg.addColorStop(0,COL.clay2);
zg.addColorStop(.55,COL.clay);
zg.addColorStop(.88,darken(COL.clay,.2));
zg.addColorStop(1,'rgba(0,0,0,0)');
G.globalAlpha=z.depth;
G.fillStyle=zg;
G.beginPath();G.arc(z.sx,z.sy,clayR,0,Math.PI*2);G.fill();
G.globalAlpha=1;
}
// C. Draw lifted tiles for each zone ON TOP of the clay
for(const z of ST.zones){
if(z.depth<.01)continue;
const liftPx=z.depth*(z.big?20:12);
for(const t of z.tiles){
const ox=t.liftX*liftPx, oy=t.liftY*liftPx;
G.save();
// Move tile to lifted position (translate by lift amount)
G.translate(ox, oy);
// Draw tile polygon
G.beginPath();
G.moveTo(t.poly[0].x,t.poly[0].y);
for(let k=1;k<t.poly.length;k++)G.lineTo(t.poly[k].x,t.poly[k].y);
G.closePath();
// Wax fill on tile
const twg=G.createLinearGradient(CX-R*.3,CY-R*.35,CX+R*.2,CY+R*.25);
twg.addColorStop(0,COL.wax[0]);twg.addColorStop(.5,COL.wax[1]);twg.addColorStop(1,COL.wax[2]);
G.fillStyle=twg;
G.fill();
// Tile edge: bright top edge (lifted = catches light)
G.strokeStyle=`rgba(255,255,255,${z.depth*.70})`;
G.lineWidth=clamp(z.depth*3.5,1.2,4.5);
G.lineJoin='round';
G.stroke();
// Shadow under tile
G.shadowColor=`rgba(0,0,0,${z.depth*.30})`;
G.shadowBlur=z.depth*10;
G.shadowOffsetX=ox*.3;
G.shadowOffsetY=oy*.3;
G.fillStyle=twg;
G.fill();
G.restore();
}
}
}
function drawKnot(){
const kx=CX,ky=CY-R+4;
const kg=G.createLinearGradient(kx-15,ky-20,kx+15,ky+3);
kg.addColorStop(0,'rgba(255,255,255,.82)');kg.addColorStop(.5,'rgba(215,215,232,.62)');kg.addColorStop(1,'rgba(168,168,200,.74)');
G.fillStyle=kg;
G.beginPath();
G.moveTo(kx-8,ky-20);G.lineTo(kx+8,ky-20);G.lineTo(kx+11,ky-11);G.lineTo(kx+9,ky);G.lineTo(kx-9,ky);G.lineTo(kx-11,ky-11);
G.closePath();G.fill();
G.strokeStyle='rgba(255,255,255,.52)';G.lineWidth=1.2;G.stroke();
}
function darken(hex,amt){
const n=parseInt(hex.replace('#',''),16);
const r=clamp(((n>>16)&255)*(1-amt)|0,0,255);
const gv=clamp(((n>>8)&255)*(1-amt)|0,0,255);
const b=clamp((n&255)*(1-amt)|0,0,255);
return `rgb(${r},${gv},${b})`;
}
(function loop(){draw();requestAnimationFrame(loop);})();
// ═══════════════════════════════════════════
// AUDIO
// ═══════════════════════════════════════════
function getAC(){if(!window._ac)window._ac=new(window.AudioContext||window.webkitAudioContext)();return window._ac;}
function popSnap(ac,t,vol,freq){
const len=Math.floor(ac.sampleRate*.038);
const buf=ac.createBuffer(1,len,ac.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/len,1.1);
const src=ac.createBufferSource();src.buffer=buf;
const hp=ac.createBiquadFilter();hp.type='highpass';hp.frequency.value=600;
const bp=ac.createBiquadFilter();bp.type='bandpass';bp.frequency.value=freq;bp.Q.value=2.8;
const g=ac.createGain();
g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(vol,t+.003);g.gain.exponentialRampToValueAtTime(.001,t+.042);
src.connect(hp);hp.connect(bp);bp.connect(g);g.connect(ac.destination);src.start(t);
}
function playCrunch(big){
try{
const ac=getAC(),now=ac.currentTime,n=big?11:5;
for(let i=0;i<n;i++){
const t=now+i*(big?.022:.028)+Math.random()*.008;
popSnap(ac,t,big?(.36+Math.random()*.24):(.26+Math.random()*.18),900+Math.random()*2400);
}
const len2=Math.floor(ac.sampleRate*.10);
const buf2=ac.createBuffer(1,len2,ac.sampleRate);
const d2=buf2.getChannelData(0);
for(let i=0;i<len2;i++)d2[i]=(Math.random()*2-1)*.7*Math.pow(1-i/len2,2.8);
const s2=ac.createBufferSource();s2.buffer=buf2;
const lp=ac.createBiquadFilter();lp.type='lowpass';lp.frequency.value=260;
const g2=ac.createGain();
g2.gain.setValueAtTime(big?.50:.25,now);g2.gain.exponentialRampToValueAtTime(.001,now+.10);
s2.connect(lp);lp.connect(g2);g2.connect(ac.destination);s2.start(now);
}catch(e){}
}
// ═══════════════════════════════════════════
// HIT TEST
// ═══════════════════════════════════════════
function toCV(cx,cy){
const r=CV.getBoundingClientRect(),sc=CV.width/r.width;
return[(cx-r.left)*sc,(cy-r.top)*sc];
}
function onBall(cx,cy){const[x,y]=toCV(cx,cy);return Math.hypot(x-CX,y-CY)<=R;}
// ═══════════════════════════════════════════
// INPUT
// ═══════════════════════════════════════════
const curEl=document.getElementById('cur');
document.addEventListener('mousemove',e=>{
curEl.style.left=e.clientX+'px';curEl.style.top=e.clientY+'px';
if(onBall(e.clientX,e.clientY)){curEl.style.display='block';document.body.style.cursor='none';}
else{curEl.style.display='none';document.body.style.cursor='';}
});
const stEl=document.getElementById('stage');
stEl.addEventListener('mousedown',e=>{
if(e.button===2){if(!onBall(e.clientX,e.clientY))return;ST.isLong=true;curEl.className='p2';curEl.textContent='✊';doPress(e.clientX,e.clientY,true);return;}
if(!onBall(e.clientX,e.clientY))return;
ST.isLong=false;curEl.className='p1';curEl.textContent='☝️';
const cx=e.clientX,cy=e.clientY;
ST.pressTimer=setTimeout(()=>{ST.isLong=true;curEl.className='p2';curEl.textContent='✊';doPress(cx,cy,true);},430);
});
stEl.addEventListener('mouseup',e=>{
clearTimeout(ST.pressTimer);curEl.className='';curEl.textContent='☝️';
if(e.button===2||!onBall(e.clientX,e.clientY))return;
if(!ST.isLong)doPress(e.clientX,e.clientY,false);
ST.isLong=false;
});
stEl.addEventListener('contextmenu',e=>e.preventDefault());
stEl.addEventListener('mouseleave',()=>{clearTimeout(ST.pressTimer);curEl.className='';curEl.textContent='☝️';});
stEl.addEventListener('touchstart',e=>{
e.preventDefault();const t=e.touches[0];
if(!onBall(t.clientX,t.clientY))return;ST.isLong=false;
const cx=t.clientX,cy=t.clientY;
ST.pressTimer=setTimeout(()=>{ST.isLong=true;doPress(cx,cy,true);},430);
},{passive:false});
stEl.addEventListener('touchend',e=>{
e.preventDefault();clearTimeout(ST.pressTimer);
if(!ST.isLong){const t=e.changedTouches[0];doPress(t.clientX,t.clientY,false);}
ST.isLong=false;
},{passive:false});
// ═══════════════════════════════════════════
// VFX
// ═══════════════════════════════════════════
function spawnPcl(cx,cy,big){
const stage=document.getElementById('stage');
const cnt=big?15:7,sc=SZ/W;
const[sx,sy]=toCV(cx,cy);
const ox=sx*sc,oy=sy*sc;
for(let i=0;i<cnt;i++){
const el=document.createElement('div');el.className='pcl';
const w=(big?7:4)+Math.random()*(big?9:4);
const h=(big?3:2)+Math.random()*(big?5:3);
const a=Math.random()*Math.PI*2;
const dist=(big?88:50)+Math.random()*(big?58:28);
const dur=.42+Math.random()*.48;
const rot=(Math.random()*600-300)+'deg';
const col=Math.random()<.5?COL.wax[1]:COL.clay;
el.style.cssText=`width:${w}px;height:${h}px;background:${col};position:absolute;left:${ox}px;top:${oy}px;border-radius:3px;--tx:${Math.cos(a)*dist}px;--ty:${Math.sin(a)*dist}px;--d:${dur}s;--rot:${rot}`;
stage.appendChild(el);setTimeout(()=>el.remove(),dur*1000+100);
}
}
const BSFX=['뽀각뽀각!!','와그작!!','뿌지직!!','으드득!!'];
const SSFX=['뽀각','오도독','바각','으득'];
function spawnPop(cx,cy,big){
const stage=document.getElementById('stage');
const sc=SZ/W;const[sx,sy]=toCV(cx,cy);
const ox=sx*sc,oy=sy*sc;
const arr=big?BSFX:SSFX;
const el=document.createElement('div');el.className='pop';
el.style.cssText=`position:absolute;left:${ox}px;top:${oy}px;color:${big?'#d03010':'#1848a0'};font-size:${big?1.9:1.5}rem;`;
el.textContent=arr[Math.floor(Math.random()*arr.length)];
stage.appendChild(el);setTimeout(()=>el.remove(),920);
}
// ═══════════════════════════════════════════
// STATS
// ═══════════════════════════════════════════
function updStats(big){
ST.total++;if(big)ST.bigs++;
ST.combo++;clearTimeout(ST.comboTimer);
ST.comboTimer=setTimeout(()=>{ST.combo=0;document.getElementById('cbo').style.opacity='0';},1900);
if(ST.combo>ST.maxCombo)ST.maxCombo=ST.combo;
bmp('sT',ST.total);bmp('sC',ST.maxCombo);bmp('sB',ST.bigs);
const cd=document.getElementById('cbo');
if(ST.combo>=3){cd.textContent=`🔥 ${ST.combo} 콤보!!`;cd.style.opacity='1';}
else cd.style.opacity='0';
}
function bmp(id,v){const e=document.getElementById(id);e.textContent=v;e.classList.add('bmp');setTimeout(()=>e.classList.remove('bmp'),210);}
// ═══════════════════════════════════════════
// PALETTE UI
// ═══════════════════════════════════════════
const grid=document.getElementById('pg');
PAL.forEach((c,i)=>{
const btn=document.createElement('div');
btn.className='sw'+(i===0?' on':'');btn.title=c.name;
btn.style.background=`linear-gradient(135deg,${c.wax[1]} 50%,${c.clay} 50%)`;
const nm=document.createElement('div');nm.className='swnm';nm.textContent=c.name;
btn.appendChild(nm);
btn.addEventListener('click',()=>{
document.querySelectorAll('.sw').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');COL=c;
});
grid.appendChild(btn);
});
</script>
</body>
</html>