Initial Dansori character workspace
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user