v2.2.14: Advanced Pixel Office Customization & Face Directions

This commit is contained in:
g1nation
2026-05-16 20:11:57 +09:00
parent c4f01fd6af
commit 9dcc98ad33
11 changed files with 154 additions and 75 deletions
+111 -44
View File
@@ -4091,12 +4091,17 @@ function addChar(st){
chars[st.key]=ch;
anim[st.key]={row:st.charRow,frame:0,dir:0,mode:'sit',face:st.face,route:0};
const img=ch.querySelector('img');
img.src=png('idle-r'+st.charRow+'-f0');
img.style.transform=st.face==='R'?'scaleX(-1)':'none';
if(st.face === 'U' || st.face === 'D'){
img.src=png('walk-r'+st.charRow+'-d'+_faceToWalkDir(st.face)+'-f0');
img.style.transform='none';
} else {
img.src=png('idle-r'+st.charRow+'-f0');
img.style.transform=st.face==='R'?'scaleX(-1)':'none';
}
return ch;
}
function buildStation(st){ addDesk(st); addChar(st); }
function buildStation(st){ addDesk(st); if(!st.noChar) addChar(st); }
function _renderDefaultStations(){
// default 8 stations + default props.
@@ -4114,8 +4119,17 @@ const DIR_DOWN = 0; // 정면 (카메라/사용자 쪽)
const DIR_LEFT = 1;
const DIR_RIGHT = 2;
const DIR_UP = 3; // 뒤
// idle/work 의 정면(D) / 후면(U) sprite 는 별도로 없으므로 walk 의 같은 방향
// frame 0 (정지 포즈) 를 그대로 빌려쓴다.
function _faceToWalkDir(face){
if(face === 'D') return DIR_DOWN;
if(face === 'U') return DIR_UP;
if(face === 'L') return DIR_LEFT;
return DIR_RIGHT;
}
function setSprite(role,mode,frame=0,dir=0){
const ch=chars[role],a=anim[role];
const ch=chars[role]; if(!ch) return;
const a=anim[role]; if(!a) return;
a.mode=mode;a.frame=frame;a.dir=dir;
ch.classList.toggle('walking',mode==='walk');
const img=ch.querySelector('img');
@@ -4123,6 +4137,10 @@ function setSprite(role,mode,frame=0,dir=0){
// 4방향 walk sprite — 좌우 sprite를 따로 제공하므로 scaleX 반전은 *안* 한다.
img.src=png('walk-r'+a.row+'-d'+dir+'-f'+frame);
img.style.transform='none';
} else if(a.face === 'U' || a.face === 'D'){
// 위/아래 face 는 idle/work sprite 가 없으므로 walk 의 같은 방향 정지 포즈로.
img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
img.style.transform='none';
} else if(mode==='work'){
img.src=png('work-r'+a.row+'-f'+frame);
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
@@ -4132,8 +4150,9 @@ function setSprite(role,mode,frame=0,dir=0){
}
}
function move(role,x,y){
const ch=chars[role],a=anim[role],
cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
const ch=chars[role]; if(!ch) return; // 캐릭터 삭제된 자리면 무시.
const a=anim[role]; if(!a) return;
const cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
dx=x-cx,dy=y-cy;
// 주축 결정: 큰 쪽을 우선해 4방향 중 하나로 매핑. 동률이면 가로 우선.
let dir;
@@ -4232,8 +4251,22 @@ function walkPath(role,points,done,route){
const tail=planned.slice(1).concat(rest);
setTimeout(()=>walkPath(role,tail,done,token),950);
}
function sendHome(role,mode='sit'){const st=stationByKey[role],ch=chars[role],hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);anim[role].route++;if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode))}
setInterval(()=>{const free=Object.keys(chars).filter(k=>anim[k].mode==='sit'&&!chars[k].classList.contains('active'));if(!free.length)return;const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k],pt=st.roam[Math.floor(Math.random()*st.roam.length)];walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'))},5600)
function sendHome(role,mode='sit'){
const st=stationByKey[role],ch=chars[role];
if(!st || !ch) return; // 책상/캐릭터 부재 시 no-op.
const hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);
anim[role].route++;
if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}
walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode));
}
setInterval(()=>{
const free=Object.keys(chars).filter(k=>anim[k]?.mode==='sit'&&!chars[k].classList.contains('active'));
if(!free.length)return;
const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k];
if(!st || !Array.isArray(st.roam) || !st.roam.length || !Array.isArray(st.dock)) return;
const pt=st.roam[Math.floor(Math.random()*st.roam.length)];
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
},5600);
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';b.textContent=text;b.style.left=(parseFloat(ch.style.left)+28)+'px';b.style.top=(parseFloat(ch.style.top)-6)+'px';stage.appendChild(b);setTimeout(()=>b.remove(),1600)}
// ── A. 상태 계층화 ──
@@ -4491,26 +4524,30 @@ function _snapshotLayout(){
// _restoreLayout 이 양쪽 모두 처리.
return {
schema: 2,
cells: stations.map(st=>({
roleKey: st.key,
agentKey: st.agentKey || '',
label: st.label || '',
charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main',
face: st.face || 'R',
boss: !!st.boss,
dock: st.dock,
roam: st.roam,
deskX: parseFloat(__deskWrap[st.key].style.left),
deskY: parseFloat(__deskWrap[st.key].style.top),
deskW: parseFloat(__deskWrap[st.key].style.width),
deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
seatX: parseFloat(chars[st.key].style.left),
seatY: parseFloat(chars[st.key].style.top),
charRot: parseFloat(chars[st.key].dataset.rot || '0'),
charZ: parseFloat(chars[st.key].dataset.z || '0'),
})),
cells: stations.map(st=>{
const ch = chars[st.key];
return {
roleKey: st.key,
agentKey: st.agentKey || '',
label: st.label || '',
charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main',
face: st.face || 'R',
boss: !!st.boss,
noChar: !!st.noChar,
dock: st.dock,
roam: st.roam,
deskX: parseFloat(__deskWrap[st.key].style.left),
deskY: parseFloat(__deskWrap[st.key].style.top),
deskW: parseFloat(__deskWrap[st.key].style.width),
deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
seatX: ch ? parseFloat(ch.style.left) : (st.seatX ?? 0),
seatY: ch ? parseFloat(ch.style.top) : (st.seatY ?? 0),
charRot: ch ? parseFloat(ch.dataset.rot || '0') : 0,
charZ: ch ? parseFloat(ch.dataset.z || '0') : 0,
};
}),
objs: Array.from(stage.querySelectorAll('img.obj')).map(el=>({
id: el.dataset.objId,
name: el.dataset.objName,
@@ -4564,6 +4601,7 @@ function _restoreLayout(snap){
deskSprite: c.deskSprite || 'desk-main',
face: c.face || 'R',
boss: !!c.boss,
noChar: !!c.noChar,
dock: Array.isArray(c.dock) ? c.dock : [c.deskX+32, c.deskY+80],
roam: Array.isArray(c.roam) ? c.roam : [[c.deskX, c.deskY+120],[c.deskX+60, c.deskY+100]],
deskX: c.deskX, deskY: c.deskY, deskW: c.deskW || 112,
@@ -4684,13 +4722,20 @@ function _renderDeskProps(deskEl){
for(let r=0;r<8;r++){
thumbs += '<div class="pp-thumb'+(r===st.charRow?' active':'')+'" data-charrow="'+r+'" title="row '+r+'"><img src="'+png('idle-r'+r+'-f0')+'"></div>';
}
const hasChar = !!chars[role];
panel.innerHTML =
'<h4>책상 속성</h4>'+
'<div class="pp-row"><label>라벨</label><input id="ppLabel" value="'+(st.label||'').replace(/"/g,'&quot;')+'"></div>'+
'<div class="pp-row"><label>에이전트 매핑</label><select id="ppAgent">'+agentOpts+'</select></div>'+
'<div class="pp-row"><label>책상 sprite</label><select id="ppDesk">'+deskOpts+'</select></div>'+
(hasChar ? '' : '<div class="pp-row"><button id="ppAddChar" style="width:100%;padding:6px;background:rgba(16,185,129,.22);border:1px solid rgba(16,185,129,.55);color:#F1F4FB;border-radius:4px;cursor:pointer;font-size:11px">+ 이 책상에 캐릭터 추가</button></div>')+
'<div class="pp-row"><label>착석 캐릭터 (row 0~7)</label><div class="pp-thumbs" id="ppThumbs">'+thumbs+'</div></div>'+
'<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace"><option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option><option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option></select></div>';
'<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace">'+
'<option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option>'+
'<option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option>'+
'<option value="U"'+(st.face==='U'?' selected':'')+'>↑ Up (뒷모습)</option>'+
'<option value="D"'+(st.face==='D'?' selected':'')+'>↓ Down (정면)</option>'+
'</select></div>';
// 핸들러
panel.querySelector('#ppLabel').oninput = (ev)=>{
st.label = ev.target.value;
@@ -4718,8 +4763,17 @@ function _renderDeskProps(deskEl){
panel.querySelector('#ppFace').onchange = (ev)=>{
st.face = ev.target.value;
if(anim[role]) anim[role].face = st.face;
const ch = chars[role]; if(ch){ const img=ch.querySelector('img'); if(img) img.style.transform = st.face==='R'?'scaleX(-1)':'none'; }
// 현재 mode 기준 sprite 즉시 다시 그리기 — U/D 도 한 번에 반영.
const a = anim[role]; if(a) setSprite(role, a.mode || 'sit', 0, 0);
};
const addCharBtn = panel.querySelector('#ppAddChar');
if(addCharBtn){
addCharBtn.onclick = ()=>{
st.noChar = false;
addChar(st);
_renderDeskProps(deskEl); // 패널 재렌더 — "캐릭터 추가" 버튼 제거.
};
}
}
function _renderObjProps(el){
@@ -4795,30 +4849,43 @@ function _openPropPicker(){
}
// ── 선택 항목 삭제 ──
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
// · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
// · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
// · 프랍 선택 → 프랍 삭제.
function _deleteSelected(){
if(!_editMode || !_selected) return;
// char 가 선택돼 있으면 그 desk 도 함께 묶어서 처리.
let target = _selected;
if(target.classList.contains('char')){
const role = Object.keys(chars).find(k=>chars[k]===target);
if(role && __deskWrap[role]) target = __deskWrap[role];
if(_selected.classList.contains('char')){
const role = Object.keys(chars).find(k=>chars[k]===_selected);
if(!role) return;
const st = stationByKey[role];
if(!confirm('"'+(st?.label||role)+'" 책상의 캐릭터를 삭제할까요? 책상은 그대로 남습니다.')) return;
_selected.remove();
delete chars[role]; delete anim[role];
if(st) st.noChar = true;
_selected = null;
_onSelectionChanged();
return;
}
if(target.classList.contains('desk')){
const role = target.dataset.role;
if(!confirm('"'+(stationByKey[role]?.label||role)+'" 책상을 삭제할까요? 이 자리에 매핑된 에이전트도 자리가 사라집니다.')) return;
target.remove();
if(_selected.classList.contains('desk')){
const role = _selected.dataset.role;
if(!confirm('"'+(stationByKey[role]?.label||role)+'" 책상을 삭제할까요? 캐릭터도 함께 사라집니다.')) return;
_selected.remove();
if(chars[role]) chars[role].remove();
delete __deskWrap[role]; delete chars[role]; delete anim[role];
const idx = stations.findIndex(s=>s.key===role);
if(idx>=0) stations.splice(idx,1);
_rebuildStationIndex();
} else if(target.classList.contains('obj')){
target.remove();
} else {
_selected = null;
_onSelectionChanged();
return;
}
if(_selected.classList.contains('obj')){
_selected.remove();
_selected = null;
_onSelectionChanged();
return;
}
_selected = null;
_onSelectionChanged();
}
function _findDraggable(el){