Initial Dansori character workspace

This commit is contained in:
eKeerar
2026-07-04 10:34:46 +09:00
commit 5a419816ff
2480 changed files with 38692 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Viewer.md — 리그 뷰어 (`index.html`) 사용법
자립형 캔버스 런타임(프로토타입). **더블클릭만으로** 노을 리그가 60fps로 춤춘다(이미지 없이 플레이스홀더로).
> 현 단계 뷰어는 **리그 클립 재생기**(Phase 1 검증용). 반응 시퀀서(베이크드+표정 레이어 합성)는 Phase 2에서 확장 → `../08_Roadmap/Roadmap.md`.
## 실행
- `index.html` 를 브라우저로 열기(더블클릭). 서버·빌드 불필요. 기본 리그/애니메이션 내장.
## 컨트롤
| 버튼 | 기능 |
|---|---|
| ⏸/▶ | 재생 토글 · 속도 슬라이더 0~2배 |
| 🖼 아트 사용 | 파츠 PNG로 렌더 ↔ 플레이스홀더 |
| 🦴 스켈레톤 | 관절점·본 라인 오버레이(튜닝용) |
| 📂 rig.json / animation.json | 외부 파일 로드(수정본 반영) |
| 🖼 파츠 PNG(다중) | 파츠 이미지 직접 지정(파일명 매칭) |
## 이미지 붙이기
1. **자동**: ChatGPT 결과를 `../03_Assets/Parts/Images/` 에 정확한 파일명으로 저장 → 뷰어 자동 로드 → 🖼 아트 사용 ON.
2. **수동**(상대경로 차단 시): 🖼 파츠 PNG(다중)로 직접 선택. 우측 패널 `파츠 이미지 로드: N/16` 확인.
## 튜닝
- 🦴+🖼 켜고 분홍 관절점이 아트 관절에 오도록 `../04_Rig/rig.json``imgAnchor/pos` 수정 → 📂 rig.json 재로드.
- 모션은 `../05_Animation/dance_idle.json` 수정 → 📂 animation.json 재로드.
## 참고
- 내장 리그/클립은 `../04_Rig/rig.json`·`../05_Animation/dance_idle.json` 의 사본. 파일 수정 후 📂로 로드하거나 index.html 상단 `DEFAULT_RIG`/`DEFAULT_ANIM` 갱신.
+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noeul Rig Viewer — dance_idle (full-canvas)</title>
<style>
:root { color-scheme: dark; }
body { margin:0; font-family: system-ui, "Segoe UI", sans-serif; background:#0e1014; color:#e6e8ee; }
header { padding:10px 16px; border-bottom:1px solid #232733; display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
h1 { font-size:15px; margin:0; font-weight:600; color:#9ff0e0; }
.wrap { display:flex; gap:16px; padding:16px; flex-wrap:wrap; }
.stage { background:
linear-gradient(45deg,#1a1d24 25%,transparent 25%,transparent 75%,#1a1d24 75%),
linear-gradient(45deg,#1a1d24 25%,#15171d 25%,#15171d 75%,#1a1d24 75%);
background-size:24px 24px; background-position:0 0,12px 12px; border:1px solid #232733; border-radius:8px; }
canvas { display:block; }
.panel { min-width:250px; max-width:340px; font-size:13px; line-height:1.6; }
.row { display:flex; align-items:center; gap:8px; margin:6px 0; flex-wrap:wrap; }
button { background:#1d2a3a; color:#cfe6ff; border:1px solid #2c4a63; border-radius:6px; padding:6px 12px; cursor:pointer; font-size:13px; }
button:hover { background:#26405a; }
button.on { background:#2e9e8b; color:#04231d; border-color:#2e9e8b; }
label.file { display:inline-block; background:#232733; border:1px solid #37414f; border-radius:6px; padding:6px 10px; cursor:pointer; }
input[type=file]{ display:none; } input[type=range]{ width:130px; }
.muted { color:#8a93a6; font-size:12px; } code { background:#191c23; padding:1px 5px; border-radius:4px; color:#9ff0e0; }
</style>
</head>
<body>
<header>
<h1>노을 Rig Viewer — <span id="clipName">dance_idle</span> <span class="muted">(full-canvas)</span></h1>
<div class="row">
<button id="playBtn" class="on">⏸ 일시정지</button>
<span class="muted">속도</span><input type="range" id="speed" min="0" max="2" step="0.05" value="1"><span id="speedV" class="muted">1.0×</span>
<button id="boneBtn">🦴 스켈레톤</button>
</div>
<div class="row">
<label class="file">📂 rig.json<input type="file" id="rigFile" accept=".json"></label>
<label class="file">📂 animation.json<input type="file" id="animFile" accept=".json"></label>
<label class="file">🖼 파츠 PNG(다중)<input type="file" id="imgFiles" accept="image/png" multiple></label>
</div>
</header>
<div class="wrap">
<div class="stage"><canvas id="cv" width="520" height="900"></canvas></div>
<div class="panel">
<p id="status">풀캔버스 파츠를 <code>../03_Assets/Parts/Images/</code> 에서 자동 로드합니다.</p>
<p class="muted">• 파츠는 520×900 풀캔버스라 <b>원점에 그리고 관절 피벗을 중심으로 회전</b>합니다.<br>
• 상대경로 자동 로드가 막히면(브라우저 보안) <b>파츠 PNG(다중)</b>으로 직접 지정.<br>
<b>스켈레톤</b>: 분홍 점 = 자동 산출한 관절 피벗.</p>
<p class="muted" id="loadInfo"></p>
</div>
</div>
<script>
const DEFAULT_RIG = {
"name":"Noeul","canvas":{"width":520,"height":900},"imageBase":"../03_Assets/Parts/Images/","mode":"fullcanvas",
"bones":[
{"name":"pelvis","parent":null,"pivot":[259.1,440.4],"z":6,"image":"noeul_part_pelvis.png"},
{"name":"chest","parent":"pelvis","pivot":[259.1,440.4],"z":8,"image":"noeul_part_chest.png"},
{"name":"neck","parent":"chest","pivot":[260.0,187.8],"z":9,"image":"noeul_part_neck.png"},
{"name":"head","parent":"neck","pivot":[259.4,164.4],"z":10,"image":"noeul_part_head.png"},
{"name":"upperarm_r","parent":"chest","pivot":[146.9,299.9],"z":5,"image":"noeul_part_upperarm_r.png"},
{"name":"forearm_r","parent":"upperarm_r","pivot":[101.9,376.0],"z":5,"image":"noeul_part_forearm_r.png"},
{"name":"hand_r","parent":"forearm_r","pivot":[59.5,435.9],"z":5,"image":"noeul_part_hand_r.png"},
{"name":"upperarm_l","parent":"chest","pivot":[373.0,300.8],"z":12,"image":"noeul_part_upperarm_l.png"},
{"name":"forearm_l","parent":"upperarm_l","pivot":[417.3,376.3],"z":12,"image":"noeul_part_forearm_l.png"},
{"name":"hand_l","parent":"forearm_l","pivot":[459.6,436.5],"z":13,"image":"noeul_part_hand_l.png"},
{"name":"thigh_r","parent":"pelvis","pivot":[207.9,513.6],"z":4,"image":"noeul_part_thigh_r.png"},
{"name":"shin_r","parent":"thigh_r","pivot":[188.4,651.7],"z":3,"image":"noeul_part_shin_r.png"},
{"name":"foot_r","parent":"shin_r","pivot":[164.5,777.8],"z":2,"image":"noeul_part_foot_r.png"},
{"name":"thigh_l","parent":"pelvis","pivot":[310.8,513.8],"z":4,"image":"noeul_part_thigh_l.png"},
{"name":"shin_l","parent":"thigh_l","pivot":[330.3,651.9],"z":3,"image":"noeul_part_shin_l.png"},
{"name":"foot_l","parent":"shin_l","pivot":[354.4,778.0],"z":2,"image":"noeul_part_foot_l.png"}
]
};
const DEFAULT_ANIM = {
"name":"dance_idle","duration":2.0,"loop":true,"defaultEase":"sine",
"tracks":{
"pelvis":{"ty":[{"t":0,"v":0},{"t":0.5,"v":10},{"t":1.0,"v":0},{"t":1.5,"v":10},{"t":2.0,"v":0}],"tx":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":-7},{"t":2.0,"v":0}],"rot":[{"t":0,"v":0},{"t":0.5,"v":2},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"chest":{"ty":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}],"tx":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}],"rot":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}]},
"neck":{"rot":[{"t":0,"v":0},{"t":0.5,"v":3},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}]},
"head":{"rot":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":-7},{"t":2.0,"v":0}],"ty":[{"t":0,"v":0},{"t":0.5,"v":-2},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"upperarm_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":8},{"t":1.0,"v":0},{"t":1.5,"v":-4},{"t":2.0,"v":0}]},
"forearm_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":12},{"t":1.0,"v":0},{"t":1.5,"v":-6},{"t":2.0,"v":0}]},
"hand_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":6},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}]},
"upperarm_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-4},{"t":1.0,"v":0},{"t":1.5,"v":8},{"t":2.0,"v":0}]},
"forearm_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-6},{"t":1.0,"v":0},{"t":1.5,"v":12},{"t":2.0,"v":0}]},
"hand_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":6},{"t":2.0,"v":0}]},
"thigh_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":3},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"shin_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":0},{"t":2.0,"v":0}]},
"thigh_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-2},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}]},
"shin_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":0},{"t":1.0,"v":0},{"t":1.5,"v":7},{"t":2.0,"v":0}]}
}
};
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
let rig, anim, bones, boneMap, playing=true, showBones=false, speed=1, t=0, last=performance.now();
const mul=(m,n)=>({a:m.a*n.a+m.c*n.b,b:m.b*n.a+m.d*n.b,c:m.a*n.c+m.c*n.d,d:m.b*n.c+m.d*n.d,e:m.a*n.e+m.c*n.f+m.e,f:m.b*n.e+m.d*n.f+m.f});
const T=(x,y)=>({a:1,b:0,c:0,d:1,e:x,f:y});
const R=deg=>{const r=deg*Math.PI/180,c=Math.cos(r),s=Math.sin(r);return{a:c,b:s,c:-s,d:c,e:0,f:0};};
const tp=(m,x,y)=>({x:m.a*x+m.c*y+m.e,y:m.b*x+m.d*y+m.f});
function ease(ty,x){return ty==='linear'?x:-(Math.cos(Math.PI*x)-1)/2;}
function sample(keys,t){ if(!keys||!keys.length)return 0; if(t<=keys[0].t)return keys[0].v; const n=keys.length; if(t>=keys[n-1].t)return keys[n-1].v;
for(let i=0;i<n-1;i++){ if(t>=keys[i].t&&t<=keys[i+1].t){ const k0=keys[i],k1=keys[i+1],sp=(k1.t-k0.t)||1e-6; return k0.v+(k1.v-k0.v)*ease(k0.e||anim.defaultEase||'sine',(t-k0.t)/sp);} } return keys[n-1].v; }
function setRig(r){ rig=r; cv.width=rig.canvas.width; cv.height=rig.canvas.height;
bones=rig.bones.map(b=>Object.assign({},b)); boneMap={}; bones.forEach(b=>{boneMap[b.name]=b; b._img=null;});
loadArt(); refreshInfo(); }
function setAnim(a){ anim=a; document.getElementById('clipName').textContent=a.name||'clip'; t=0; }
function loadArt(){ const base=rig.imageBase||'../03_Assets/Parts/Images/';
bones.forEach(b=>{ if(!b.image)return; const im=new Image(); im.onload=()=>{b._img=im;refreshInfo();}; im.onerror=()=>{b._img=null;refreshInfo();}; im.src=base+b.image; }); }
function refreshInfo(){ if(!bones)return; const ok=bones.filter(b=>b._img).length; document.getElementById('loadInfo').textContent=`파츠 이미지 로드: ${ok} / ${bones.length}`+(ok===0?' (없음 → 스켈레톤만)':''); }
function updateWorld(t){ bones.forEach(b=>{ const tr=anim.tracks[b.name]||{}; const rot=sample(tr.rot,t),tx=sample(tr.tx,t),ty=sample(tr.ty,t);
const jx=b.pivot[0],jy=b.pivot[1]; const local=mul(T(tx,ty),mul(T(jx,jy),mul(R(rot),T(-jx,-jy))));
b._world=b.parent?mul(boneMap[b.parent]._world,local):local; }); }
function render(){ ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cv.width,cv.height);
bones.slice().sort((a,b)=>(a.z||0)-(b.z||0)).forEach(b=>{ if(!b._img)return; const w=b._world;
ctx.setTransform(w.a,w.b,w.c,w.d,w.e,w.f); ctx.drawImage(b._img,0,0); });
ctx.setTransform(1,0,0,1,0,0);
if(showBones){ bones.forEach(b=>{ const p=tp(b._world,b.pivot[0],b.pivot[1]);
if(b.parent){ const pp=boneMap[b.parent]; const q=tp(pp._world,pp.pivot[0],pp.pivot[1]); ctx.strokeStyle='rgba(255,90,130,.7)'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(q.x,q.y); ctx.lineTo(p.x,p.y); ctx.stroke(); } });
bones.forEach(b=>{ const p=tp(b._world,b.pivot[0],b.pivot[1]); ctx.fillStyle='#ff5a82'; ctx.beginPath(); ctx.arc(p.x,p.y,4,0,Math.PI*2); ctx.fill(); }); }
}
function frame(now){ const dt=(now-last)/1000; last=now; if(playing){ t+=dt*speed; if(t>=anim.duration)t%=anim.duration; } updateWorld(t); render(); requestAnimationFrame(frame); }
const playBtn=document.getElementById('playBtn'), boneBtn=document.getElementById('boneBtn');
playBtn.onclick=()=>{ playing=!playing; playBtn.textContent=playing?'⏸ 일시정지':'▶ 재생'; playBtn.classList.toggle('on',playing); };
boneBtn.onclick=()=>{ showBones=!showBones; boneBtn.classList.toggle('on',showBones); };
document.getElementById('speed').oninput=e=>{ speed=parseFloat(e.target.value); document.getElementById('speedV').textContent=speed.toFixed(2)+'×'; };
function readJson(f,cb){ const r=new FileReader(); r.onload=()=>{try{cb(JSON.parse(r.result));}catch(err){alert('JSON 파싱 실패: '+err.message);}}; r.readAsText(f); }
document.getElementById('rigFile').onchange=e=>{ if(e.target.files[0])readJson(e.target.files[0],setRig); };
document.getElementById('animFile').onchange=e=>{ if(e.target.files[0])readJson(e.target.files[0],setAnim); };
document.getElementById('imgFiles').onchange=e=>{ [...e.target.files].forEach(f=>{ const b=bones.find(x=>x.image===f.name); if(!b)return; const im=new Image(); im.onload=()=>{b._img=im;refreshInfo();}; im.src=URL.createObjectURL(f); }); };
setRig(DEFAULT_RIG); setAnim(DEFAULT_ANIM); requestAnimationFrame(frame);
</script>
</body>
</html>
+494
View File
@@ -0,0 +1,494 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noeul Reaction Sequencer — reactions.html</title>
<style>
:root { color-scheme: dark; }
body { margin:0; font-family: system-ui, "Segoe UI", sans-serif; background:#0e1014; color:#e6e8ee; }
header { padding:10px 16px; border-bottom:1px solid #232733; display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
h1 { font-size:15px; margin:0; font-weight:600; color:#9ff0e0; }
.wrap { display:flex; gap:16px; padding:16px; flex-wrap:wrap; }
.stage { background:
linear-gradient(45deg,#1a1d24 25%,transparent 25%,transparent 75%,#1a1d24 75%),
linear-gradient(45deg,#1a1d24 25%,#15171d 25%,#15171d 75%,#1a1d24 75%);
background-size:24px 24px; background-position:0 0,12px 12px; border:1px solid #232733; border-radius:8px; }
canvas { display:block; }
.panel { min-width:250px; max-width:360px; font-size:13px; line-height:1.6; }
.row { display:flex; align-items:center; gap:8px; margin:6px 0; flex-wrap:wrap; }
button { background:#1d2a3a; color:#cfe6ff; border:1px solid #2c4a63; border-radius:6px; padding:6px 12px; cursor:pointer; font-size:13px; }
button:hover { background:#26405a; }
button.on { background:#2e9e8b; color:#04231d; border-color:#2e9e8b; }
button.trig { background:#2a2536; border-color:#4a3d63; color:#e2d6ff; }
button.trig:hover { background:#39304d; }
label.file { display:inline-block; background:#232733; border:1px solid #37414f; border-radius:6px; padding:6px 10px; cursor:pointer; }
input[type=file]{ display:none; } input[type=range]{ width:130px; }
.muted { color:#8a93a6; font-size:12px; } code { background:#191c23; padding:1px 5px; border-radius:4px; color:#9ff0e0; }
.tag { font-size:11px; color:#9ff0e0; background:#12241f; border:1px solid #1f4238; border-radius:10px; padding:1px 8px; }
</style>
</head>
<body>
<header>
<h1>노을 Reaction Sequencer — <span id="stateName">dance_idle</span> <span class="muted">(520×900 / file://)</span></h1>
<div class="row">
<button id="playBtn" class="on">⏸ 일시정지</button>
<span class="muted">속도</span><input type="range" id="speed" min="0" max="2" step="0.05" value="1"><span id="speedV" class="muted">1.0×</span>
<button id="capBtn" class="on">💬 말풍선</button>
</div>
</header>
<div class="wrap">
<div class="stage"><canvas id="cv" width="520" height="900"></canvas></div>
<div class="panel">
<div class="row" id="trigRow"></div>
<p class="muted" id="status">상황 트리거를 누르면 반응 클립을 재생하고 종료 후 <code>dance_idle</code> 로 복귀합니다.</p>
<div class="row">
<label class="file">🖼 이미지 로드(다중)<input type="file" id="imgFiles" accept="image/png" multiple></label>
</div>
<p class="muted">• 리그 idle 은 풀캔버스 파츠를 원점에 그리고 관절 피벗 기준 회전.<br>
• baked 반응은 headless 바디 + 표정 머리를 <b>목(neck) 부착점</b>에 합성 (Python <code>reactions_layout_render.py</code> 와 동일 어파인).<br>
• 상대경로 로드가 막히면 <b>이미지 로드(다중)</b> 로 PNG 를 직접 지정(파일명 매칭).</p>
<p class="muted" id="loadInfo"></p>
</div>
</div>
<script>
/* ============================================================
Embedded JSON (inlined so it runs from file:// with zero fetch)
============================================================ */
const RIG = {
"name":"Noeul","canvas":{"width":520,"height":900},"imageBase":"../03_Assets/Parts/Images/","mode":"fullcanvas",
"bones":[
{"name":"pelvis","parent":null,"pivot":[259.1,440.4],"z":6,"image":"noeul_part_pelvis.png"},
{"name":"chest","parent":"pelvis","pivot":[259.1,440.4],"z":8,"image":"noeul_part_chest.png"},
{"name":"neck","parent":"chest","pivot":[260.0,187.8],"z":9,"image":"noeul_part_neck.png"},
{"name":"head","parent":"neck","pivot":[259.4,164.4],"z":10,"image":"noeul_part_head.png"},
{"name":"upperarm_r","parent":"chest","pivot":[146.9,299.9],"z":5,"image":"noeul_part_upperarm_r.png"},
{"name":"forearm_r","parent":"upperarm_r","pivot":[101.9,376.0],"z":5,"image":"noeul_part_forearm_r.png"},
{"name":"hand_r","parent":"forearm_r","pivot":[59.5,435.9],"z":5,"image":"noeul_part_hand_r.png"},
{"name":"upperarm_l","parent":"chest","pivot":[373.0,300.8],"z":12,"image":"noeul_part_upperarm_l.png"},
{"name":"forearm_l","parent":"upperarm_l","pivot":[417.3,376.3],"z":12,"image":"noeul_part_forearm_l.png"},
{"name":"hand_l","parent":"forearm_l","pivot":[459.6,436.5],"z":13,"image":"noeul_part_hand_l.png"},
{"name":"thigh_r","parent":"pelvis","pivot":[207.9,513.6],"z":4,"image":"noeul_part_thigh_r.png"},
{"name":"shin_r","parent":"thigh_r","pivot":[188.4,651.7],"z":3,"image":"noeul_part_shin_r.png"},
{"name":"foot_r","parent":"shin_r","pivot":[164.5,777.8],"z":2,"image":"noeul_part_foot_r.png"},
{"name":"thigh_l","parent":"pelvis","pivot":[310.8,513.8],"z":4,"image":"noeul_part_thigh_l.png"},
{"name":"shin_l","parent":"thigh_l","pivot":[330.3,651.9],"z":3,"image":"noeul_part_shin_l.png"},
{"name":"foot_l","parent":"shin_l","pivot":[354.4,778.0],"z":2,"image":"noeul_part_foot_l.png"}
]
};
const DANCE_IDLE = {
"name":"dance_idle","duration":2.0,"loop":true,"fpsHint":60,"defaultEase":"sine",
"tracks":{
"pelvis":{"ty":[{"t":0,"v":0},{"t":0.5,"v":10},{"t":1.0,"v":0},{"t":1.5,"v":10},{"t":2.0,"v":0}],"tx":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":-7},{"t":2.0,"v":0}],"rot":[{"t":0,"v":0},{"t":0.5,"v":2},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"chest":{"ty":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}],"tx":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}],"rot":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}]},
"neck":{"rot":[{"t":0,"v":0},{"t":0.5,"v":3},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}]},
"head":{"rot":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":-7},{"t":2.0,"v":0}],"ty":[{"t":0,"v":0},{"t":0.5,"v":-2},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"upperarm_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":8},{"t":1.0,"v":0},{"t":1.5,"v":-4},{"t":2.0,"v":0}]},
"forearm_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":12},{"t":1.0,"v":0},{"t":1.5,"v":-6},{"t":2.0,"v":0}]},
"hand_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":6},{"t":1.0,"v":0},{"t":1.5,"v":-3},{"t":2.0,"v":0}]},
"upperarm_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-4},{"t":1.0,"v":0},{"t":1.5,"v":8},{"t":2.0,"v":0}]},
"forearm_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-6},{"t":1.0,"v":0},{"t":1.5,"v":12},{"t":2.0,"v":0}]},
"hand_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-3},{"t":1.0,"v":0},{"t":1.5,"v":6},{"t":2.0,"v":0}]},
"thigh_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":3},{"t":1.0,"v":0},{"t":1.5,"v":-2},{"t":2.0,"v":0}]},
"shin_r":{"rot":[{"t":0,"v":0},{"t":0.5,"v":7},{"t":1.0,"v":0},{"t":1.5,"v":0},{"t":2.0,"v":0}]},
"thigh_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":-2},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0}]},
"shin_l":{"rot":[{"t":0,"v":0},{"t":0.5,"v":0},{"t":1.0,"v":0},{"t":1.5,"v":7},{"t":2.0,"v":0}]}
}
};
const REACTIONS = {
"name":"Noeul reactions map","idleDefault":"dance_idle",
"map":{"idle":"dance_idle","error":"gesture_no","success":"gesture_heart","focus":"gesture_focus"}
};
const LAYOUT = {
"stage":{"w":520,"h":900},"neck":[260,250],"overlap":6,"headTargetW":150,
"bodies":{
"noeul_body_cozy_armscross":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_cheer":{"scale":150.0,"ox":260.0,"oy":-3350.0},
"noeul_body_cozy_clap":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_control":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_dj":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_handwave":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_heart":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_idle_full":{"scale":1.145,"ox":-37.7,"oy":84.0},
"noeul_body_cozy_idle_upper":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_joy":{"scale":0.3036,"ox":181.2,"oy":228.4},
"noeul_body_cozy_listen":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_peace":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_piano":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_point":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_present":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_shrug":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_thumbsup":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_cozy_wave":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_armscross":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_cheer":{"scale":150.0,"ox":260.0,"oy":-3350.0},
"noeul_body_day_clap":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_control":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_dj":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_handwave":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_heart":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_idle_full":{"scale":1.145,"ox":-37.7,"oy":84.0},
"noeul_body_day_idle_upper":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_joy":{"scale":0.3036,"ox":181.2,"oy":228.4},
"noeul_body_day_listen":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_peace":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_piano":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_point":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_present":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_shrug":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_thumbsup":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_day_wave":{"scale":1.145,"ox":-37.7,"oy":222.5},
"noeul_body_night_armscross":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_cheer":{"scale":150.0,"ox":260.0,"oy":-3350.0},
"noeul_body_night_clap":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_control":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_dj":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_handwave":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_heart":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_idle_full":{"scale":150.0,"ox":-44140.0,"oy":-21500.0},
"noeul_body_night_idle_upper":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_joy":{"scale":0.3036,"ox":181.2,"oy":228.4},
"noeul_body_night_listen":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_peace":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_piano":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_point":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_present":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_shrug":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_thumbsup":{"scale":150.0,"ox":-44140.0,"oy":-3350.0},
"noeul_body_night_wave":{"scale":150.0,"ox":-44140.0,"oy":-3350.0}
},
"heads":{
"noeul_head_wave":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_blink":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_confused":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_cool":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_laugh":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_love":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_negative":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_neutral":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_playful":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_positive":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_pout":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_proud":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_sad":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_shy":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_sleepy":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_smile":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_surprised":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_talk":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_talk_wide":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_thinking":{"w":293,"neckNorm":[0.5651,0.8359]},
"noeul_head_wave_wink":{"w":293,"neckNorm":[0.5651,0.8359]}
}
};
const CLIPS = {
"gesture_no":{
"name":"gesture_no","duration":2.8,"return":"idle",
"layers":{
"body":[{"t":0.0,"mode":"rig","clip":"idle"},{"t":0.2,"mode":"baked","image":"noeul_body_cozy_armscross","fade":0.3}],
"face":[{"t":0.0,"expr":"neutral"},{"t":0.4,"expr":"negative"}],
"mouth":[{"t":0.7,"say":"음, 안 돼요","dur":1.3,"pattern":"talk"}],
"transform":{
"chest":{"ty":[{"t":0.0,"v":0},{"t":0.3,"v":-3},{"t":0.6,"v":0}]},
"head":{"rot":[{"t":0.7,"v":0},{"t":1.1,"v":6},{"t":1.6,"v":-6},{"t":2.1,"v":4},{"t":2.5,"v":0}]}
},
"caption":[{"t":0.7,"text":"음, 안 돼요~","dur":1.7}],
"sfx":[{"t":0.6,"id":"soft_no"}]
}
},
"gesture_heart":{
"name":"gesture_heart","duration":2.4,"return":"idle",
"layers":{
"body":[{"t":0.0,"mode":"rig","clip":"idle"},{"t":0.2,"mode":"baked","image":"noeul_body_cozy_heart","fade":0.3}],
"face":[{"t":0.0,"expr":"smile"},{"t":0.3,"expr":"love"}],
"mouth":[{"t":0.6,"say":"잘됐어요","dur":1.1,"pattern":"talk"}],
"transform":{
"pelvis":{"ty":[{"t":0.5,"v":0},{"t":0.9,"v":5},{"t":1.3,"v":0},{"t":1.7,"v":5},{"t":2.1,"v":0}]},
"head":{"rot":[{"t":0.5,"v":0},{"t":0.9,"v":3},{"t":1.3,"v":-3},{"t":1.7,"v":3},{"t":2.1,"v":0}]}
},
"caption":[{"t":0.6,"text":"잘됐어요~","dur":1.5}],
"sfx":[{"t":0.55,"id":"soft_success"}]
}
},
"gesture_focus":{
"name":"gesture_focus","duration":4.0,"loopHint":true,"return":"idle",
"layers":{
"body":[{"t":0.0,"mode":"rig","clip":"idle"},{"t":0.3,"mode":"baked","image":"noeul_body_cozy_listen","fade":0.4}],
"face":[{"t":0.0,"expr":"neutral"},{"t":0.5,"expr":"sleepy"}],
"mouth":[{"t":1.0,"say":"음~","dur":0.8,"pattern":"talk"}],
"transform":{
"head":{"rot":[{"t":0.0,"v":0},{"t":0.5,"v":4},{"t":1.0,"v":0},{"t":1.5,"v":4},{"t":2.0,"v":0},{"t":2.5,"v":4},{"t":3.0,"v":0},{"t":3.5,"v":4},{"t":4.0,"v":0}],
"ty":[{"t":0.0,"v":0},{"t":0.5,"v":3},{"t":1.0,"v":0},{"t":1.5,"v":3},{"t":2.0,"v":0},{"t":2.5,"v":3},{"t":3.0,"v":0},{"t":3.5,"v":3},{"t":4.0,"v":0}]},
"pelvis":{"ty":[{"t":0.0,"v":0},{"t":1.0,"v":3},{"t":2.0,"v":0},{"t":3.0,"v":3},{"t":4.0,"v":0}]}
},
"caption":[{"t":1.0,"text":"♪","dur":3.0}],
"sfx":[{"t":0.5,"id":"lofi_loop","loop":true}]
}
}
};
/* ============================================================
Asset paths
============================================================ */
const PARTS_DIR = RIG.imageBase || "../03_Assets/Parts/Images/";
const HEAD_DIR = "../03_Assets/Library/Heads/";
const BAKED_DIRS = ["../03_Assets/Library/BakedPoses/Cozy/","../03_Assets/Library/BakedPoses/Day/","../03_Assets/Library/BakedPoses/Night/"];
/* images keyed by basename (no extension) */
const images = {}; // key -> HTMLImageElement (loaded)
let loadTotal = 0, loadOk = 0;
function tryLoad(key, dirs){
loadTotal++;
let i = 0;
const attempt = () => {
if (i >= dirs.length) { refreshInfo(); return; } // give up (leave undefined)
const im = new Image();
im.onload = () => { images[key] = im; loadOk++; refreshInfo(); };
im.onerror = () => { i++; attempt(); };
im.src = dirs[i] + key + ".png";
};
attempt();
}
function loadAllAssets(){
// rig parts
RIG.bones.forEach(b => { if (b.image) tryLoad(b.image.replace(/\.png$/i,""), [PARTS_DIR]); });
// heads (all expressions + talk frames)
Object.keys(LAYOUT.heads).forEach(k => tryLoad(k, [HEAD_DIR]));
// baked bodies referenced by clips (+ common ones); resolve across Cozy/Day/Night
const bakedNeeded = new Set();
Object.values(CLIPS).forEach(c => (c.layers.body||[]).forEach(e => { if (e.mode==="baked" && e.image) bakedNeeded.add(e.image); }));
bakedNeeded.forEach(k => tryLoad(k, BAKED_DIRS));
}
function refreshInfo(){
document.getElementById('loadInfo').textContent = `이미지 로드: ${loadOk} / ${loadTotal}` + (loadOk===0?' (없음 → 이미지 로드(다중) 사용)':'');
}
/* ============================================================
Matrix + sampling helpers (same convention as index.html)
mul(A,B) = A·B (B applied first, then A); tp = A·point
============================================================ */
const mul=(m,n)=>({a:m.a*n.a+m.c*n.b,b:m.b*n.a+m.d*n.b,c:m.a*n.c+m.c*n.d,d:m.b*n.c+m.d*n.d,e:m.a*n.e+m.c*n.f+m.e,f:m.b*n.e+m.d*n.f+m.f});
const T=(x,y)=>({a:1,b:0,c:0,d:1,e:x,f:y});
const R=deg=>{const r=deg*Math.PI/180,c=Math.cos(r),s=Math.sin(r);return{a:c,b:s,c:-s,d:c,e:0,f:0};};
const Sc=k=>({a:k,b:0,c:0,d:k,e:0,f:0});
function ease(ty,x){return ty==='linear'?x:-(Math.cos(Math.PI*x)-1)/2;}
function sample(keys,t,defEase){ if(!keys||!keys.length)return 0; if(t<=keys[0].t)return keys[0].v; const n=keys.length; if(t>=keys[n-1].t)return keys[n-1].v;
for(let i=0;i<n-1;i++){ if(t>=keys[i].t&&t<=keys[i+1].t){ const k0=keys[i],k1=keys[i+1],sp=(k1.t-k0.t)||1e-6; return k0.v+(k1.v-k0.v)*ease(k0.e||defEase||'sine',(t-k0.t)/sp);} } return keys[n-1].v; }
function sT(track,sub,t){ return (track && track[sub]) ? sample(track[sub],t) : 0; }
// active timeline entry: last with t<=now (or first)
function activeEntry(arr,now){ if(!arr||!arr.length)return null; let e=arr[0]; for(let i=0;i<arr.length;i++){ if(arr[i].t<=now) e=arr[i]; else break; } return e; }
function activeIndex(arr,now){ let idx=0; for(let i=0;i<arr.length;i++){ if(arr[i].t<=now) idx=i; else break; } return idx; }
/* ============================================================
Rig FK render (copied engine from index.html) with optional
per-bone extra deltas and whole-stage translate.
============================================================ */
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
cv.width=RIG.canvas.width; cv.height=RIG.canvas.height;
const bones=RIG.bones.map(b=>Object.assign({},b));
const boneMap={}; bones.forEach(b=>boneMap[b.name]=b);
function rigWorlds(time, extra){
extra = extra||{};
bones.forEach(b=>{
const tr=DANCE_IDLE.tracks[b.name]||{};
const ex=extra[b.name]||{};
const rot=sample(tr.rot,time,DANCE_IDLE.defaultEase)+(ex.rot||0);
const tx =sample(tr.tx ,time,DANCE_IDLE.defaultEase)+(ex.tx ||0);
const ty =sample(tr.ty ,time,DANCE_IDLE.defaultEase)+(ex.ty ||0);
const jx=b.pivot[0],jy=b.pivot[1];
const local=mul(T(tx,ty),mul(T(jx,jy),mul(R(rot),T(-jx,-jy))));
b._world=b.parent?mul(boneMap[b.parent]._world,local):local;
});
}
function drawRig(time, extra, stageTx, stageTy, alpha){
rigWorlds(time, extra);
const base=T(stageTx||0, stageTy||0);
ctx.globalAlpha = alpha==null?1:alpha;
bones.slice().sort((a,b)=>(a.z||0)-(b.z||0)).forEach(b=>{
const im=images[b.image.replace(/\.png$/i,"")]; if(!im)return;
const w=mul(base,b._world);
ctx.setTransform(w.a,w.b,w.c,w.d,w.e,w.f); ctx.drawImage(im,0,0);
});
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
/* ============================================================
Baked composition — mirrors reactions_layout_render.py compose()
body: M = T(stage) · T(ox,oy) · Sc(scale)
head: M = T(stage) · T(NECK_X, NECK_Y+OVERLAP+headTy) · R(rot) · Sc(hs) · T(-ax,-ay)
============================================================ */
const NECK_X = LAYOUT.neck[0], NECK_Y = LAYOUT.neck[1], OVERLAP = LAYOUT.overlap, HEAD_TARGET_W = LAYOUT.headTargetW;
function drawBakedBody(imgKey, stageTx, stageTy, alpha){
const bl=LAYOUT.bodies[imgKey]; const im=images[imgKey];
if(!bl||!im)return;
const M=mul(T(stageTx||0,stageTy||0), mul(T(bl.ox,bl.oy), Sc(bl.scale)));
ctx.globalAlpha=alpha==null?1:alpha;
ctx.setTransform(M.a,M.b,M.c,M.d,M.e,M.f); ctx.drawImage(im,0,0);
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
function headKeyFallback(key){
return images[key] ? key : (images['noeul_head_wave_neutral'] ? 'noeul_head_wave_neutral' : (images['noeul_head_wave'] ? 'noeul_head_wave' : null));
}
function drawHead(headKey, rot, headTy, stageTx, stageTy, alpha){
const key=headKeyFallback(headKey); if(!key)return;
const hl=LAYOUT.heads[headKey]||LAYOUT.heads[key]; const im=images[key]; if(!hl||!im)return;
const hs=HEAD_TARGET_W/hl.w;
const ax=hl.neckNorm[0]*im.naturalWidth, ay=hl.neckNorm[1]*im.naturalHeight;
const M=mul(T(stageTx||0,stageTy||0),
mul(T(NECK_X, NECK_Y+OVERLAP+(headTy||0)),
mul(R(rot||0), mul(Sc(hs), T(-ax,-ay)))));
ctx.globalAlpha=alpha==null?1:alpha;
ctx.setTransform(M.a,M.b,M.c,M.d,M.e,M.f); ctx.drawImage(im,0,0);
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
/* current expression + mouth lip-sync frame */
function currentHeadKey(clip, now){
const face=clip.layers.face; const fe=activeEntry(face,now);
const expr=fe?fe.expr:'neutral';
const exprKey='noeul_head_wave_'+expr;
const mouth=clip.layers.mouth||[];
for(const m of mouth){
if(now>=m.t && now < m.t+(m.dur||0)){
// ~7Hz swap between talk / expr / talk_wide to approximate lip-sync
const seq=['noeul_head_wave_talk', exprKey, 'noeul_head_wave_talk_wide', exprKey];
return seq[Math.floor((now-m.t)*7)%seq.length];
}
}
return exprKey;
}
/* baked body layer draw (body + head) at given alpha */
function drawBakedLayer(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha){
drawBakedBody(entry.image, stageTx, stageTy, alpha);
drawHead(currentHeadKey(clip, now), headRot, headTy, stageTx, stageTy, alpha);
}
/* rig body layer draw (rig already includes head; add head rot/ty delta) */
function drawRigLayer(clip, now, stageTx, stageTy, headRot, headTy, alpha){
drawRig(now, { head:{ rot:headRot, ty:headTy } }, stageTx, stageTy, alpha);
}
function drawBodyEntry(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha){
if(entry.mode==='baked') drawBakedLayer(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha);
else drawRigLayer(clip, now, stageTx, stageTy, headRot, headTy, alpha);
}
/* speech bubble caption near the head */
let showCaption=true;
function drawCaption(clip, now, stageTx, stageTy){
if(!showCaption)return;
const cap=(clip.layers.caption||[]).find(c=>now>=c.t && now<c.t+(c.dur||0));
if(!cap)return;
const cx=NECK_X+(stageTx||0), topY=NECK_Y-190+(stageTy||0);
ctx.save(); ctx.setTransform(1,0,0,1,0,0);
ctx.font='600 20px system-ui, "Segoe UI", sans-serif';
const tw=ctx.measureText(cap.text).width;
const padX=14, padY=10, bw=tw+padX*2, bh=34, bx=cx-bw/2, by=topY-bh;
ctx.fillStyle='rgba(20,24,30,0.92)'; ctx.strokeStyle='#2e9e8b'; ctx.lineWidth=2;
roundRect(bx,by,bw,bh,10); ctx.fill(); ctx.stroke();
// tail
ctx.beginPath(); ctx.moveTo(cx-8,by+bh); ctx.lineTo(cx+8,by+bh); ctx.lineTo(cx,by+bh+12); ctx.closePath();
ctx.fillStyle='rgba(20,24,30,0.92)'; ctx.fill();
ctx.fillStyle='#e6f7f2'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(cap.text, cx, by+bh/2);
ctx.restore();
}
function roundRect(x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
/* ============================================================
Reaction render (full frame)
============================================================ */
function renderReaction(clip, now){
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cv.width,cv.height);
const tr=clip.layers.transform||{};
const stageTx=sT(tr.pelvis,'tx',now)+sT(tr.chest,'tx',now);
const stageTy=sT(tr.pelvis,'ty',now)+sT(tr.chest,'ty',now);
const headRot=sT(tr.head,'rot',now);
const headTy =sT(tr.head,'ty',now);
const entries=clip.layers.body||[];
const ai=activeIndex(entries,now);
const cur=entries[ai];
const fade=cur?cur.fade||0:0;
if(cur && fade>0 && ai>0 && now < cur.t+fade){
const p=Math.max(0,Math.min(1,(now-cur.t)/fade));
drawBodyEntry(clip, entries[ai-1], now, stageTx, stageTy, headRot, headTy, 1-p); // outgoing
drawBodyEntry(clip, cur, now, stageTx, stageTy, headRot, headTy, p); // incoming
} else if(cur){
drawBodyEntry(clip, cur, now, stageTx, stageTy, headRot, headTy, 1);
}
drawCaption(clip, now, stageTx, stageTy);
}
function renderIdle(time){
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cv.width,cv.height);
drawRig(time, null, 0, 0, 1);
}
/* ============================================================
Playback loop / state machine
============================================================ */
let stateMode='idle'; // 'idle' | 'reaction'
let curClip=null, reactTime=0, idleTime=0;
let playing=true, speed=1, last=performance.now();
function playClip(name){
const clip=CLIPS[name];
if(!clip){ stateMode='idle'; setStateName('dance_idle'); return; }
curClip=clip; reactTime=0; stateMode='reaction'; setStateName(clip.name);
}
function goIdle(){ stateMode='idle'; curClip=null; setStateName('dance_idle'); }
function setStateName(n){ document.getElementById('stateName').textContent=n; }
function frame(now){
const dt=(now-last)/1000; last=now;
if(playing){
if(stateMode==='reaction' && curClip){
reactTime+=dt*speed;
if(reactTime>=curClip.duration){ goIdle(); }
} else {
idleTime+=dt*speed; if(idleTime>=DANCE_IDLE.duration) idleTime%=DANCE_IDLE.duration;
}
}
if(stateMode==='reaction' && curClip) renderReaction(curClip, reactTime);
else renderIdle(idleTime);
requestAnimationFrame(frame);
}
/* ============================================================
UI
============================================================ */
const trigRow=document.getElementById('trigRow');
Object.keys(REACTIONS.map).forEach(key=>{
const clipName=REACTIONS.map[key];
const btn=document.createElement('button');
btn.className='trig';
btn.innerHTML=`${key} <span class="tag">${clipName}</span>`;
btn.onclick=()=>{
if(key==='idle' || clipName===REACTIONS.idleDefault){ idleTime=0; goIdle(); }
else playClip(clipName);
};
trigRow.appendChild(btn);
});
const playBtn=document.getElementById('playBtn'), capBtn=document.getElementById('capBtn');
playBtn.onclick=()=>{ playing=!playing; playBtn.textContent=playing?'⏸ 일시정지':'▶ 재생'; playBtn.classList.toggle('on',playing); };
capBtn.onclick=()=>{ showCaption=!showCaption; capBtn.classList.toggle('on',showCaption); };
document.getElementById('speed').oninput=e=>{ speed=parseFloat(e.target.value); document.getElementById('speedV').textContent=speed.toFixed(2)+'×'; };
document.getElementById('imgFiles').onchange=e=>{
[...e.target.files].forEach(f=>{
const key=f.name.replace(/\.png$/i,"");
const im=new Image(); im.onload=()=>{ if(!images[key])loadOk++; images[key]=im; refreshInfo(); }; im.src=URL.createObjectURL(f);
});
};
loadAllAssets(); refreshInfo(); requestAnimationFrame(frame);
</script>
</body>
</html>