2003 lines
90 KiB
TypeScript
2003 lines
90 KiB
TypeScript
// 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등.
|
||
// `${assets.derivedBase}` placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입.
|
||
// 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.
|
||
|
||
const OFFICE_RUNTIME_JS_TEMPLATE = `
|
||
<script>(function(){
|
||
const base='\${assets.derivedBase}'; const stage=document.getElementById('stage');
|
||
const officeEl = stage && stage.closest ? stage.closest('.office') : null;
|
||
if(officeEl){
|
||
officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v3.png\")');
|
||
officeEl.classList.add('has-art');
|
||
}
|
||
let _stageScale = 1;
|
||
function _fitStage(){
|
||
const shell = stage && stage.closest ? stage.closest('.office') : null;
|
||
if(!stage || !shell) return;
|
||
const sx = Math.max(.62, (shell.clientWidth - 28) / 720);
|
||
const sy = Math.max(.62, (shell.clientHeight - 28) / 585);
|
||
_stageScale = Math.min(1, sx, sy);
|
||
stage.style.transform = 'scale(' + _stageScale + ')';
|
||
stage.style.transformOrigin = 'center center';
|
||
}
|
||
window.addEventListener('resize', _fitStage);
|
||
setTimeout(_fitStage, 0);
|
||
// \u2500\u2500 \uB370\uC774\uD130 \uBAA8\uB378 \u2500\u2500
|
||
// stations: \uCC45\uC0C1 + \uCE90\uB9AD\uD130 \uC815\uC758 \uBC30\uC5F4 (let \u2014 \uCD94\uAC00/\uC81C\uAC70 \uAC00\uB2A5).
|
||
// key = \uC548\uC815\uC801 \uC2DD\uBCC4\uC790 (DOM dataset.role \uB85C\uB3C4 \uC0AC\uC6A9). \uC0AC\uC6A9\uC790\uAC00 \uC0C8\uB85C \uB9CC\uB4E0 \uCC45\uC0C1\uC740 \uC790\uB3D9 \uC0DD\uC131.
|
||
// agentKey = \uC774 \uCC45\uC0C1\uC5D0 \uB9E4\uD551\uB41C \uC5D0\uC774\uC804\uD2B8 ID (ceo/planner/researcher/...). \uC5C6\uC73C\uBA74 idle.
|
||
// charRow = \uC0AC\uC6A9\uD560 sprite row (0~7). \uCE90\uB9AD\uD130 \uC678\uBAA8/\uBC29\uD5A5.
|
||
// deskSprite= \uCC45\uC0C1 PNG \uC774\uB984 (desk-main / desk-boss / desk-dark-mirror \uB4F1).
|
||
// objs: \uD504\uB78D \uC815\uC758 \uBC30\uC5F4 (let). user-add/remove.
|
||
// 기본 장면은 더 이상 "가짜 8인 사무실"을 깔지 않는다.
|
||
// 실제 roster 가 들어오면 그 인원만으로 장면을 짓고, roster 가 비면 빈 오피스 그대로 둔다.
|
||
const DEFAULT_STATIONS=[];
|
||
const LEGACY_SHOWROOM_POS={
|
||
ceo:[304,84],planner:[60,228],researcher:[236,214],designer:[402,240],
|
||
developer:[56,410],qa:[232,394],inspector:[408,420],support:[220,472],
|
||
};
|
||
// \uC5D0\uC774\uC804\uD2B8 ID alias \u2014 \uAC19\uC740 \uD398\uB974\uC18C\uB098\uC758 \uB2E4\uC591\uD55C \uD638\uCE6D\uC744 \uAC19\uC740 book agentKey \uB85C. (writer\u2192planner, editor\u2192designer, secretary\u2192support, business\u2192inspector)
|
||
const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',business:'inspector'};
|
||
// \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set.
|
||
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
|
||
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG).
|
||
const DESK_SPRITE_CHOICES=['astra-desk-work','astra-desk-dark','astra-desk-exec'];
|
||
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4.
|
||
const PROP_SPRITE_CHOICES=['astra-storage-low','astra-bookshelf-slim','astra-plant-corner','astra-cooler-slim','astra-printer-compact','astra-chair-task'];
|
||
const SPRITE_LABELS={
|
||
'astra-desk-work':'워크 데스크',
|
||
'astra-desk-dark':'다크 데스크',
|
||
'astra-desk-exec':'대표 데스크',
|
||
'astra-storage-low':'로우 스토리지',
|
||
'astra-bookshelf-slim':'북케이스',
|
||
'astra-plant-corner':'코너 플랜트',
|
||
'astra-cooler-slim':'정수기',
|
||
'astra-printer-compact':'프린터',
|
||
'astra-chair-task':'태스크 체어',
|
||
};
|
||
// 후면 배경 자체가 이미 충분히 강하므로, 목적 없는 프랍은 기본 장면에서 제거.
|
||
// 커스텀 편집 모드에선 여전히 사용자가 원하는 프랍을 직접 추가할 수 있다.
|
||
const DEFAULT_PROPS=[];
|
||
|
||
let stations=[]; // mutable, \uC2DC\uC791 \uC2DC default \uB610\uB294 saved layout \uB85C \uCC44\uC6C0.
|
||
let _hasSavedLayout=false;
|
||
let _layoutSource='default';
|
||
let _lastAutoLayoutSignature='';
|
||
let _latestSnapshot=null;
|
||
let __nextDeskN=100; // user-add \uCC45\uC0C1 id \uCE74\uC6B4\uD130 (default \uC640 \uCDA9\uB3CC \uD68C\uD53C \uC704\uD574 \uD070 \uC218\uC5D0\uC11C \uC2DC\uC791).
|
||
let __nextObjN=0;
|
||
const stationByKey={}; // \uBE60\uB978 lookup. stations \uBCC0\uACBD \uC2DC rebuild.
|
||
const __deskWrap={}; // role \u2192 desk DOM wrap.
|
||
const chars={}; // role \u2192 char DOM.
|
||
const anim={}; // role \u2192 animation state.
|
||
// 한 번 처리된 agentId 는 여기에 기록 → 사용자가 그 desk 를 지워도 자동 재생성 안 함.
|
||
const _autoDeskedFor = new Set();
|
||
|
||
function png(name){return base+'/'+name+'.png'}
|
||
function _spriteLabel(name){ return SPRITE_LABELS[name] || name; }
|
||
|
||
function _rebuildStationIndex(){
|
||
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
|
||
stations.forEach(st=>{ stationByKey[st.key]=st; });
|
||
}
|
||
function _safeDeskKey(raw){
|
||
return 'pipe_' + String(raw || 'agent').toLowerCase().replace(/[^a-z0-9_-]+/g,'_');
|
||
}
|
||
function _uniqueAgentsById(list){
|
||
const seen=new Set(), out=[];
|
||
for(const item of list||[]){
|
||
if(!item || !item.agentId || seen.has(item.agentId)) continue;
|
||
seen.add(item.agentId); out.push(item);
|
||
}
|
||
return out;
|
||
}
|
||
function _inferRoleFromStageLabel(label){
|
||
const s=String(label||'').toLowerCase();
|
||
if(/qa|테스트|검증/.test(s)) return 'qa';
|
||
if(/검토|감리|review/.test(s)) return 'inspector';
|
||
if(/디자인|ux|ui/.test(s)) return 'designer';
|
||
if(/시장|트렌드|조사|리서치|research/.test(s)) return 'researcher';
|
||
if(/설계|개발|구현|배포|deploy|dev/.test(s)) return 'developer';
|
||
if(/기획|방향|문서|plan/.test(s)) return 'planner';
|
||
return null;
|
||
}
|
||
function _resolvedStageAgentId(stage, roster){
|
||
const explicit = stage && (stage.agentId || stage.agent);
|
||
if(explicit) return explicit;
|
||
const role = _inferRoleFromStageLabel(stage && stage.label);
|
||
if(!role) return null;
|
||
const match = (roster||[]).find(r=>r && r.roleCategory===role);
|
||
return match ? match.agentId : null;
|
||
}
|
||
function _pipelineRosterOrder(roster, pipeline){
|
||
const list=Array.isArray(roster)?roster.slice():[];
|
||
if(!pipeline || !Array.isArray(pipeline.stages) || !pipeline.stages.length) return list;
|
||
const byId=new Map(list.map(r=>[r.agentId,r]));
|
||
const ordered=[];
|
||
const ceo=byId.get('ceo');
|
||
if(ceo) ordered.push(ceo);
|
||
for(const stage of pipeline.stages){
|
||
const aid=_resolvedStageAgentId(stage,list);
|
||
const agent=aid && byId.get(aid);
|
||
if(agent) ordered.push(agent);
|
||
}
|
||
for(const agent of list) ordered.push(agent);
|
||
return _uniqueAgentsById(ordered);
|
||
}
|
||
function _stationSpriteFor(slot, roleCategory){
|
||
if(roleCategory==='ceo') return 'astra-desk-exec';
|
||
return 'astra-desk-work';
|
||
}
|
||
function _stationSlots(count){
|
||
const rows=[];
|
||
if(count <= 0) return rows;
|
||
const cols = count <= 2 ? count : (count >= 7 ? 4 : 3);
|
||
const rowCount = Math.ceil(count / cols);
|
||
const xSets = {
|
||
1:[308],
|
||
2:[216,400],
|
||
3:[124,308,492],
|
||
4:[78,230,382,534],
|
||
};
|
||
for(let row=0; row<rowCount; row++){
|
||
const remaining = count - row*cols;
|
||
const inRow = Math.min(cols, remaining);
|
||
const xs = xSets[inRow] || xSets[3];
|
||
const rowYs = rowCount === 1 ? [300] : (rowCount === 2 ? [304,432] : [270,380,490]);
|
||
const y = rowYs[row] ?? (490 + Math.max(0,row-rowYs.length+1)*88);
|
||
xs.forEach((x)=>rows.push([x,y,'R']));
|
||
}
|
||
return rows;
|
||
}
|
||
function _makeStationForRosterAgent(agent, slot, slots){
|
||
if(agent.roleCategory==='ceo' || agent.agentId==='ceo'){
|
||
return {
|
||
key:'ceo',agentKey:agent.agentId,label:agent.agentName||'CEO',charRow:_roleCategoryToCharRow(agent.roleCategory),
|
||
deskSprite:'astra-desk-exec',deskX:286,deskY:188,deskW:140,seatX:319,seatY:218,face:'R',
|
||
dock:[350,268],roam:[[304,280],[402,280]],boss:true,
|
||
};
|
||
}
|
||
const [deskX,deskY,face]=(slots && slots[slot]) || [308,318,'R'];
|
||
const deskW=112;
|
||
const seatX=deskX+4;
|
||
const seatY=deskY+36;
|
||
return {
|
||
key:_safeDeskKey(agent.agentId),agentKey:agent.agentId,label:agent.agentName||agent.agentId,
|
||
charRow:_roleCategoryToCharRow(agent.roleCategory),deskSprite:_stationSpriteFor(slot,agent.roleCategory),
|
||
deskX,deskY,deskW,seatX,seatY,face,boss:false,
|
||
dock:[deskX+32,deskY+82],
|
||
roam:[[deskX+12,deskY+112],[deskX+74,deskY+104]],
|
||
};
|
||
}
|
||
function _rebuildScene(nextStations, props){
|
||
_clearStage();
|
||
stations=(nextStations||[]).map(st=>Object.assign({},st));
|
||
_rebuildStationIndex();
|
||
stations.forEach(buildStation);
|
||
(props||DEFAULT_PROPS).forEach(p=>addImg(p.name,p.x,p.y,p.w));
|
||
}
|
||
function _restoreDefaultScene(){
|
||
_rebuildScene(DEFAULT_STATIONS,DEFAULT_PROPS);
|
||
_layoutSource='default';
|
||
_lastAutoLayoutSignature='';
|
||
const shell=document.querySelector('.office-shell');
|
||
if(shell) shell.dataset.layout='default';
|
||
}
|
||
function _looksLikeLegacyShowroomLayout(snap){
|
||
if(!snap || !Array.isArray(snap.cells) || snap.cells.length!==8) return false;
|
||
return snap.cells.every(c=>{
|
||
const legacy=LEGACY_SHOWROOM_POS[c && c.roleKey];
|
||
return !!legacy
|
||
&& Math.abs((c.deskX||0)-legacy[0])<=2
|
||
&& Math.abs((c.deskY||0)-legacy[1])<=2;
|
||
});
|
||
}
|
||
function _syncOfficeLayout(pipeline, roster){
|
||
if(_hasSavedLayout || _editMode) return;
|
||
const stages=pipeline && Array.isArray(pipeline.stages) ? pipeline.stages : [];
|
||
const ordered=stages.length ? _pipelineRosterOrder(roster,pipeline) : _uniqueAgentsById(roster||[]);
|
||
if(!ordered.length){
|
||
if(_layoutSource!=='default') _restoreDefaultScene();
|
||
return;
|
||
}
|
||
const signature=ordered.map(r=>r.agentId).join('|')+'::'+stages.map(s=>(s.label||'')+':'+(_resolvedStageAgentId(s,ordered)||'')).join('|');
|
||
if(_layoutSource==='roster' && signature===_lastAutoLayoutSignature) return;
|
||
const ceo=ordered.find(r=>r.agentId==='ceo' || r.roleCategory==='ceo');
|
||
const workers=ordered.filter(r=>r!==ceo);
|
||
const slots=_stationSlots(workers.length);
|
||
const next=[];
|
||
if(ceo) next.push(_makeStationForRosterAgent(ceo,0,slots));
|
||
workers.forEach((agent,idx)=>next.push(_makeStationForRosterAgent(agent,idx,slots)));
|
||
if(next.length){
|
||
_rebuildScene(next,DEFAULT_PROPS);
|
||
_layoutSource='roster';
|
||
_lastAutoLayoutSignature=signature;
|
||
const shell=document.querySelector('.office-shell');
|
||
if(shell) shell.dataset.layout=stages.length?'pipeline':'roster';
|
||
}
|
||
}
|
||
function _clearPipelineDecor(){
|
||
Object.values(__deskWrap).forEach(wrap=>{
|
||
wrap.classList.remove('pipeline-desk');
|
||
delete wrap.dataset.flow;
|
||
wrap.querySelectorAll('.flow-badge').forEach(el=>el.remove());
|
||
});
|
||
const flow=document.getElementById('flowLayer');
|
||
if(flow) flow.innerHTML='';
|
||
}
|
||
function _deskCenterForAgent(agentId){
|
||
const st=findStationByAgent(agentId);
|
||
if(!st) return null;
|
||
const wrap=__deskWrap[st.key];
|
||
if(!wrap) return null;
|
||
const x=parseFloat(wrap.style.left)+(parseFloat(wrap.style.width)||st.deskW||112)/2;
|
||
const y=parseFloat(wrap.style.top)+34;
|
||
return {x,y,st,wrap};
|
||
}
|
||
function _renderPipelineDecor(pipeline, roster){
|
||
_clearPipelineDecor();
|
||
const stages=pipeline && Array.isArray(pipeline.stages) ? pipeline.stages : [];
|
||
if(!stages.length) return;
|
||
const stageAgents=stages.map(stg=>({ stage:stg, agentId:_resolvedStageAgentId(stg,roster) })).filter(x=>!!x.agentId);
|
||
if(!stageAgents.length) return;
|
||
const meta=new Map();
|
||
stageAgents.forEach((item,idx)=>{
|
||
const prev=meta.get(item.agentId)||{first:idx+1,count:0,statuses:[]};
|
||
prev.count++;
|
||
prev.statuses.push(item.stage.status||'pending');
|
||
meta.set(item.agentId,prev);
|
||
});
|
||
meta.forEach((m,agentId)=>{
|
||
const center=_deskCenterForAgent(agentId);
|
||
if(!center) return;
|
||
const status=m.statuses.includes('active') ? 'active' : (m.statuses.every(s=>s==='done') ? 'done' : 'pending');
|
||
center.wrap.classList.add('pipeline-desk');
|
||
center.wrap.dataset.flow=status;
|
||
const badge=document.createElement('div');
|
||
badge.className='flow-badge';
|
||
badge.textContent=m.count>1 ? (m.first+'×'+m.count) : String(m.first);
|
||
center.wrap.appendChild(badge);
|
||
});
|
||
const compact=[], seenRouteAgents=new Set();
|
||
for(const item of stageAgents){
|
||
if(seenRouteAgents.has(item.agentId)) continue;
|
||
seenRouteAgents.add(item.agentId);
|
||
compact.push(item);
|
||
}
|
||
const pts=compact.map(item=>{
|
||
const center=_deskCenterForAgent(item.agentId);
|
||
const m=meta.get(item.agentId);
|
||
const status=m && m.statuses.includes('active') ? 'active' : (m && m.statuses.every(s=>s==='done') ? 'done' : 'pending');
|
||
return { ...item, status, center };
|
||
}).filter(x=>!!x.center);
|
||
const flow=document.getElementById('flowLayer');
|
||
if(!flow || pts.length<1) return;
|
||
if(pts.length>1){
|
||
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
|
||
path.setAttribute('class','flow-route');
|
||
path.setAttribute('d',pts.map((p,i)=>(i?'L':'M')+p.center.x+' '+p.center.y).join(' '));
|
||
flow.appendChild(path);
|
||
}
|
||
pts.forEach((p)=>{
|
||
const c=document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||
c.setAttribute('class','flow-node');
|
||
c.setAttribute('cx',String(p.center.x));
|
||
c.setAttribute('cy',String(p.center.y));
|
||
c.setAttribute('r',p.status==='active'?'7':'5');
|
||
c.dataset.status=p.status||'pending';
|
||
flow.appendChild(c);
|
||
});
|
||
}
|
||
// agentKey \u2192 station.key \uB85C \uB77C\uC6B0\uD305. roleMap \uC758 \uB3D9\uC801 \uBC84\uC804.
|
||
function findStationByAgent(agentId){
|
||
if(!agentId) return null;
|
||
const a = AGENT_ALIASES[agentId] || agentId;
|
||
for(const st of stations){ if(st.agentKey === a) return st; }
|
||
return null;
|
||
}
|
||
// \uC61B \uCF54\uB4DC \uD638\uD658 \u2014 roleMap[x] \uD638\uCD9C \uD328\uD134\uC744 \uD568\uC218\uB85C.
|
||
const roleMap=new Proxy({},{get:(_,k)=>{ const s=findStationByAgent(k); return s?s.key:null; }});
|
||
|
||
function addImg(name,x,y,w){
|
||
const i=document.createElement('img');
|
||
i.src=png(name); i.className='obj';
|
||
i.dataset.objId='obj_'+(__nextObjN++); i.dataset.objName=name;
|
||
if(w!=null) i.dataset.objW=w;
|
||
i.style.left=x+'px'; i.style.top=y+'px';
|
||
if(w!=null) i.style.width=w+'px';
|
||
stage.appendChild(i); return i;
|
||
}
|
||
|
||
function addDesk(st){
|
||
const wrap=document.createElement('div');
|
||
wrap.className='desk '+(st.boss?'boss':'');
|
||
wrap.dataset.role=st.key;
|
||
if(st.agentKey) wrap.dataset.agent=st.agentKey;
|
||
wrap.style.left=st.deskX+'px'; wrap.style.top=st.deskY+'px'; wrap.style.width=st.deskW+'px';
|
||
const img=document.createElement('img'); img.src=png(st.deskSprite); img.style.width='100%';
|
||
wrap.appendChild(img);
|
||
const l=document.createElement('div'); l.className='label'; l.textContent=st.label;
|
||
wrap.appendChild(l);
|
||
stage.appendChild(wrap);
|
||
__deskWrap[st.key]=wrap;
|
||
return wrap;
|
||
}
|
||
|
||
function addChar(st){
|
||
const ch=document.createElement('div');
|
||
ch.className='char'; ch.dataset.role=st.key;
|
||
if(st.agentKey) ch.dataset.agent=st.agentKey;
|
||
ch.style.left=st.seatX+'px'; ch.style.top=st.seatY+'px';
|
||
ch.dataset.homeX=st.seatX; ch.dataset.homeY=st.seatY; ch.dataset.row=st.charRow;
|
||
ch.innerHTML='<img><div class="shadow"></div>';
|
||
stage.appendChild(ch);
|
||
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');
|
||
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); if(!st.noChar) addChar(st); }
|
||
|
||
_restoreDefaultScene();
|
||
// ── 4방향 dir 매핑 ──
|
||
// PNG 파일명 규약: walk-r<row>-d<DIR>-f<frame>.png
|
||
// 사용자의 sprite 컨벤션이 다르면 이 4 상수만 수정하면 됨.
|
||
// 기본값은 일반적인 RPG 4방향 시트 순서 (caliverse · RPGMaker 등).
|
||
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]; 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');
|
||
ch.classList.toggle('working',mode==='work');
|
||
const img=ch.querySelector('img');
|
||
if(mode==='walk'){
|
||
// 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'){
|
||
// 기존 work sprite 는 불꽃 연출이 과도해 제품 톤을 깨뜨렸다.
|
||
// 작업 중임은 CSS focus treatment 로 표현하고 캐릭터 본체는 idle 계열을 유지.
|
||
img.src=png('idle-r'+a.row+'-f'+(frame%2));
|
||
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
|
||
} else {
|
||
img.src=png('idle-r'+a.row+'-f'+frame);
|
||
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
|
||
}
|
||
}
|
||
function move(role,x,y){
|
||
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;
|
||
if(Math.abs(dx) >= Math.abs(dy)){
|
||
dir = dx >= 0 ? DIR_RIGHT : DIR_LEFT;
|
||
} else {
|
||
dir = dy >= 0 ? DIR_DOWN : DIR_UP;
|
||
}
|
||
// 도착 후 idle/sit 시 좌우 face도 마지막 가로 이동에 맞춰 갱신. UP/DOWN은
|
||
// 기존 face 유지 — 4방향 idle sprite가 없을 때 좌우만이라도 자연스럽게.
|
||
if(dir === DIR_LEFT) a.face = 'L';
|
||
if(dir === DIR_RIGHT) a.face = 'R';
|
||
setSprite(role,'walk',0,dir);
|
||
ch.style.left=x+'px';
|
||
ch.style.top=y+'px';
|
||
}
|
||
// ── Managed intervals (pause while the office view is hidden) ──
|
||
// The pixel-office runs several animation/roam/banter intervals. While the
|
||
// webview tab is not visible they do invisible work and keep timers hot —
|
||
// wasteful. _managedInterval registers each one; a visibilitychange handler
|
||
// pauses them all when the document is hidden and resumes them when shown.
|
||
// Behavior while visible is unchanged (same callbacks, same periods).
|
||
const _managedIntervals=[];
|
||
function _managedInterval(fn,ms){
|
||
const rec={fn:fn,ms:ms,id:null};
|
||
rec.id=setInterval(fn,ms);
|
||
_managedIntervals.push(rec);
|
||
return rec;
|
||
}
|
||
function _pauseManagedIntervals(){
|
||
for(const rec of _managedIntervals){ if(rec.id!==null){ clearInterval(rec.id); rec.id=null; } }
|
||
}
|
||
function _resumeManagedIntervals(){
|
||
for(const rec of _managedIntervals){ if(rec.id===null){ rec.id=setInterval(rec.fn,rec.ms); } }
|
||
}
|
||
document.addEventListener('visibilitychange',()=>{
|
||
if(document.hidden) _pauseManagedIntervals();
|
||
else _resumeManagedIntervals();
|
||
});
|
||
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},286)
|
||
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
|
||
// ── 책상 회피 path planner ──
|
||
// walkPath의 각 leg를 직선이 아닌 *책상을 우회하는* L자 또는 corridor 경로로
|
||
// 펴서 캐릭터가 책상을 가로지르지 않게. 책상이 회전됐을 때를 대비해 padding
|
||
// 충분히. 사용자 layout이 너무 빡빡해 모든 시도가 fail이면 직선 fallback.
|
||
function _deskRects(){
|
||
return stations.map(st=>{
|
||
const w=__deskWrap[st.key]; if(!w) return null;
|
||
const x=parseFloat(w.style.left), y=parseFloat(w.style.top);
|
||
const ww=parseFloat(w.style.width)||100;
|
||
const hh=w.offsetHeight||40;
|
||
const pad=20;
|
||
return {x:x-pad, y:y-pad, w:ww+pad*2, h:hh+pad*2};
|
||
}).filter(Boolean);
|
||
}
|
||
function _segIntersect(x1,y1,x2,y2,x3,y3,x4,y4){
|
||
const denom=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);
|
||
if(Math.abs(denom)<0.0001) return false;
|
||
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/denom;
|
||
const u=-((x1-x2)*(y1-y3)-(y1-y2)*(x1-x3))/denom;
|
||
return t>=0&&t<=1&&u>=0&&u<=1;
|
||
}
|
||
function _segHitsRect(x1,y1,x2,y2,r){
|
||
// 양 끝점이 rect 안이면 즉시 충돌
|
||
const inR=(x,y)=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h;
|
||
if(inR(x1,y1)||inR(x2,y2)) return true;
|
||
// 4변과 교차 검사
|
||
return _segIntersect(x1,y1,x2,y2,r.x,r.y,r.x+r.w,r.y)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y,r.x+r.w,r.y+r.h)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y+r.h,r.x,r.y+r.h)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x,r.y+r.h,r.x,r.y);
|
||
}
|
||
function _pathClear(pts,rects){
|
||
for(let i=0;i<pts.length-1;i++){
|
||
const [a,b]=[pts[i],pts[i+1]];
|
||
for(const r of rects){ if(_segHitsRect(a[0],a[1],b[0],b[1],r)) return false; }
|
||
}
|
||
return true;
|
||
}
|
||
function _planPath(fromX,fromY,toX,toY){
|
||
const rects=_deskRects();
|
||
if(rects.length===0) return [[toX,toY]];
|
||
// 1) 직선
|
||
if(_pathClear([[fromX,fromY],[toX,toY]],rects)) return [[toX,toY]];
|
||
// 2) 가로 먼저 L
|
||
if(_pathClear([[fromX,fromY],[toX,fromY],[toX,toY]],rects)) return [[toX,fromY],[toX,toY]];
|
||
// 3) 세로 먼저 L
|
||
if(_pathClear([[fromX,fromY],[fromX,toY],[toX,toY]],rects)) return [[fromX,toY],[toX,toY]];
|
||
// 4) 사무실 corridor를 통과 — y 통로 후보 (윗·중간·아랫줄 책상 사이)
|
||
// stage 높이에 따라 자동 후보 생성: 0, h/4, h/2, 3h/4, h
|
||
const sh=stage.offsetHeight||600;
|
||
const ycands=[20,Math.round(sh*0.32),Math.round(sh*0.6),Math.round(sh*0.82),sh-20];
|
||
for(const cy of ycands){
|
||
const trial=[[fromX,fromY],[fromX,cy],[toX,cy],[toX,toY]];
|
||
if(_pathClear(trial,rects)) return [[fromX,cy],[toX,cy],[toX,toY]];
|
||
}
|
||
// 5) x 통로 후보
|
||
const sw=stage.offsetWidth||700;
|
||
const xcands=[20,Math.round(sw*0.32),Math.round(sw*0.6),Math.round(sw*0.82),sw-20];
|
||
for(const cx of xcands){
|
||
const trial=[[fromX,fromY],[cx,fromY],[cx,toY],[toX,toY]];
|
||
if(_pathClear(trial,rects)) return [[cx,fromY],[cx,toY],[toX,toY]];
|
||
}
|
||
// 최후의 fallback — 직선 (책상을 가로지를 수 있지만 적어도 멈추진 않음)
|
||
return [[toX,toY]];
|
||
}
|
||
function walkPath(role,points,done,route){
|
||
const a=anim[role],token=route??++a.route;
|
||
if(token!==a.route) return;
|
||
if(!points.length){ if(done) done(); return; }
|
||
const ch=chars[role];
|
||
const cx=parseFloat(ch.style.left), cy=parseFloat(ch.style.top);
|
||
const [pt,...rest]=points;
|
||
// 현재 위치 → 다음 waypoint를 책상 회피 경로로 펴기.
|
||
const planned=_planPath(cx,cy,pt[0],pt[1]);
|
||
// 첫 step 이동하고, 나머지 planned 점들 + 원본 rest를 큐로.
|
||
const next=planned[0];
|
||
move(role,next[0],next[1]);
|
||
const tail=planned.slice(1).concat(rest);
|
||
setTimeout(()=>walkPath(role,tail,done,token),1235);
|
||
}
|
||
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));
|
||
}
|
||
_managedInterval(()=>{
|
||
if(!['idle','done'].includes(_prevStatus || 'idle')) return;
|
||
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'));
|
||
},9000);
|
||
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
|
||
|
||
// ── 검수 시나리오: 작성자를 검수자 옆으로 데려가기 + 안전거리 ──
|
||
// 작성자(writer) 가 작업 끝낸 후 status='reviewing' 진입하면 검수자 책상 옆으로 이동.
|
||
// 다른 캐릭터들과 겹치지 않도록 안전거리(>= SAFE_DIST px) 유지하면서 자리 후보 탐색.
|
||
const SAFE_DIST = 36;
|
||
function _isClearSpot(x, y, ignoreRole){
|
||
for(const k of Object.keys(chars)){
|
||
if(k === ignoreRole) continue;
|
||
const c = chars[k]; if(!c) continue;
|
||
const cx = parseFloat(c.style.left), cy = parseFloat(c.style.top);
|
||
const dx = cx - x, dy = cy - y;
|
||
if(Math.sqrt(dx*dx + dy*dy) < SAFE_DIST) return false;
|
||
}
|
||
return true;
|
||
}
|
||
function _findSpotNear(targetX, targetY, ignoreRole){
|
||
// 검수자 책상 주변 8방향 원형 탐색 — 60px 부터 시작해 비면 즉시 채택.
|
||
const offsets = [
|
||
[60, 0], [-60, 0], [0, -60], [0, 60],
|
||
[50, -40], [-50, -40], [50, 40], [-50, 40],
|
||
[80, 0], [-80, 0], [0, -70], [0, 70],
|
||
];
|
||
for(const [dx, dy] of offsets){
|
||
const x = targetX + dx, y = targetY + dy;
|
||
// stage 경계.
|
||
if(x < 20 || y < 20 || x > (stage.offsetWidth - 60) || y > (stage.offsetHeight - 80)) continue;
|
||
if(_isClearSpot(x, y, ignoreRole)) return [x, y];
|
||
}
|
||
// 모든 후보 실패 — fallback 으로 그냥 target 옆 60px (겹쳐도 어쩔 수 없음).
|
||
return [targetX + 60, targetY];
|
||
}
|
||
/**
|
||
* writerRole 캐릭터를 reviewerRole 의 자리 근처로 walk. 도착 후 standing 자세로 face 만 회전.
|
||
* reviewing 모드 종료 시 _restoreWriterHome 으로 원위치.
|
||
*/
|
||
const _movedToReviewer = new Map(); // role → original home {x, y, face}
|
||
function _walkToReviewer(writerRole, reviewerRole){
|
||
if(!writerRole || !reviewerRole) return;
|
||
if(writerRole === reviewerRole) return;
|
||
const writerCh = chars[writerRole], reviewerCh = chars[reviewerRole];
|
||
if(!writerCh || !reviewerCh) return;
|
||
if(_movedToReviewer.has(writerRole)) return; // 이미 가 있음
|
||
const rx = parseFloat(reviewerCh.style.left), ry = parseFloat(reviewerCh.style.top);
|
||
const [tx, ty] = _findSpotNear(rx, ry, writerRole);
|
||
const a = anim[writerRole]; if(!a) return;
|
||
// home 기억해뒀다가 reviewing 끝나면 복귀.
|
||
_movedToReviewer.set(writerRole, {
|
||
x: parseFloat(writerCh.style.left), y: parseFloat(writerCh.style.top), face: a.face,
|
||
});
|
||
walkPath(writerRole, [[tx, ty]], () => {
|
||
// 도착 후 검수자 쪽 face. 좌우 위치만 비교 (목적지가 reviewer 왼쪽인지 오른쪽인지).
|
||
if(a){ a.face = tx < rx ? 'R' : 'L'; }
|
||
setSprite(writerRole, 'sit');
|
||
});
|
||
}
|
||
function _restoreWriterHome(writerRole){
|
||
const saved = _movedToReviewer.get(writerRole);
|
||
if(!saved){
|
||
// 옮긴 적 없으면 그냥 sendHome.
|
||
sendHome(writerRole, 'sit');
|
||
return;
|
||
}
|
||
_movedToReviewer.delete(writerRole);
|
||
const a = anim[writerRole]; if(a) a.face = saved.face;
|
||
// station seat 좌표로 복귀 (saved.x/y 는 옛 home 이지만 그 동안 사용자 layout 편집이 있을 수 있어 station 우선).
|
||
sendHome(writerRole, 'sit');
|
||
}
|
||
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(),2400)}
|
||
|
||
// ── 로그 → 말풍선 요약기 ──
|
||
// 감리(inspector) 는 검토 결과를 키워드만 보여주고, 일반 에이전트는 짧게 잘라서 표시.
|
||
// 같은 에이전트에서 너무 자주 뜨면 산만하니 throttle.
|
||
const _lastBubbleAt = {};
|
||
const BUBBLE_MIN_GAP_MS = 2200;
|
||
const _KW_STOP = new Set([
|
||
'은','는','이','가','을','를','의','에','도','과','와','으로','에서','부터','까지','마다','보다','처럼','같이','한테','한테서','한테',
|
||
'입니다','합니다','했습니다','됩니다','있습니다','없습니다','하였습니다','하다','하는','한','했','됨','됨.',
|
||
'그리고','하지만','또는','관련','관련된','대한','대해','그래서','이번','이걸','저걸','이거','저거',
|
||
'확인','진행','수행','완료','시작','종료','발생','처리','요청','응답',
|
||
'그리고','또한','그러나','즉','또는','및',
|
||
'입력','출력','메시지','파일','코드','라인','함수','클래스','변수','값','상태',
|
||
]);
|
||
function _extractKeywords(text, count){
|
||
const tokens = String(text).split(/[\\s,\\.\\?!,。、:;\\(\\)\\[\\]"'·…\\-]+/).filter(Boolean);
|
||
const seen = new Set(); const out = [];
|
||
// 길이 내림차순 — 의미있는 명사가 짧은 조사/어미보다 먼저 뽑히도록.
|
||
const ranked = tokens
|
||
.filter(t => t.length >= 2 && !_KW_STOP.has(t) && !/^[0-9]+$/.test(t))
|
||
.sort((a,b)=> b.length - a.length);
|
||
for(const t of ranked){
|
||
if(seen.has(t)) continue;
|
||
seen.add(t); out.push(t);
|
||
if(out.length >= count) break;
|
||
}
|
||
return out;
|
||
}
|
||
function _bubbleSummary(role, raw){
|
||
const s = String(raw||'').trim().replace(/[*\\[\\]\`]/g,'');
|
||
if(!s) return '';
|
||
const st = stationByKey[role];
|
||
const isInspector = st && st.agentKey === 'inspector';
|
||
if(isInspector){
|
||
const kw = _extractKeywords(s, 3);
|
||
return kw.length ? kw.join(' · ') : (s.length > 12 ? s.slice(0,11)+'…' : s);
|
||
}
|
||
// 일반: 한 줄 + 16자 잘라.
|
||
const oneLine = s.split(/\\r?\\n/)[0];
|
||
return oneLine.length > 18 ? oneLine.slice(0,17)+'…' : oneLine;
|
||
}
|
||
function _bubbleFromLog(role, raw){
|
||
if(!role || !chars[role]) return;
|
||
const now = Date.now();
|
||
if((now - (_lastBubbleAt[role]||0)) < BUBBLE_MIN_GAP_MS) return;
|
||
const txt = _bubbleSummary(role, raw);
|
||
if(!txt) return;
|
||
_lastBubbleAt[role] = now;
|
||
bubble(role, txt);
|
||
}
|
||
|
||
// ── 속마음 라이브러리 (refactor: inner-thought bubbles) ──
|
||
// 각 직군이 작업 중 (mode='work') 무슨 생각을 하는지 — 자동 주기 emitter 가 6~9초마다
|
||
// 활성 character 중 한 명을 골라 한 줄 띄움. 사용자가 "지금 이 캐릭터가 뭘 고민 중인지"
|
||
// 느낌으로 알게. 로그 기반 _bubbleFromLog 와는 독립 — 둘 다 같은 throttle gate 통과.
|
||
const INNER_THOUGHTS = {
|
||
ceo: {
|
||
work: ['전체 그림 다시 한번', '리스크는?', '우선순위 재배치', '누구한테 맡길까', '결정 미루지 말자'],
|
||
sit: ['오늘 일정 확인…', '보고서 정리 시작', '커피 한 모금'],
|
||
},
|
||
planner: {
|
||
work: ['핵심 시나리오 빠진 거 없나', '측정 가능한 기준 추가', '범위 너무 넓어졌어', '한 문단으로 줄여보자', '독자 입장에서…'],
|
||
sit: ['오늘 뭐부터 시작할까', '레퍼런스 한 번 더', '메모 다시 보자'],
|
||
},
|
||
researcher: {
|
||
work: ['출처 한 번 더 확인', '비슷한 사례 있나', '데이터 vs 추측', '반증 케이스 찾자', '이거 일반론 아냐?'],
|
||
sit: ['검색 키워드 다듬기', '논문 한 편 더', '북마크 정리'],
|
||
},
|
||
designer: {
|
||
work: ['시각 위계 맞나', '여백 더 줄까', '컬러 톤 일관성', '모바일에선 어떻게', '사용자 흐름 막힘 없나'],
|
||
sit: ['inspiration 좀…', '컴포넌트 라이브러리 봐야', '피그마 정리'],
|
||
},
|
||
developer: {
|
||
work: ['엣지 케이스 빠짐', '이거 깨질 거 같은데', '함수 너무 길어', '테스트부터 쓸까', '에러 처리 누락'],
|
||
sit: ['이슈 목록 훑어', '리팩토링 백로그', '커밋 메시지 다듬기'],
|
||
},
|
||
qa: {
|
||
work: ['이 케이스는 어떻게', '입력 비어있으면', '동시성 문제 가능', 'race condition', '경계값 한 번 더'],
|
||
sit: ['테스트 시나리오 정리', '회귀 케이스 추가', '버그 재현 절차'],
|
||
},
|
||
inspector: {
|
||
work: ['기획 의도 일치 여부', '과한 over-engineering', '시나리오 누락', '측정 기준 명확한가', '재작업 여부 판단'],
|
||
sit: ['이전 검토 메모…', '체크리스트 정리', '기준 다듬기'],
|
||
},
|
||
support: {
|
||
work: ['일정 충돌 확인', '담당 명확한가', '리마인더 시점', '회의록 정리', '이해관계자 누락'],
|
||
sit: ['오늘 처리할 것…', '캘린더 한 번 더', '미답 답장 확인'],
|
||
},
|
||
};
|
||
function _innerThoughtFor(agentKey, mode){
|
||
const bucket = INNER_THOUGHTS[agentKey];
|
||
if(!bucket) return null;
|
||
const pool = bucket[mode] || bucket.work || [];
|
||
if(!pool.length) return null;
|
||
return pool[Math.floor(Math.random()*pool.length)];
|
||
}
|
||
// 검수 중 작성자가 inspector 옆에 있을 때 띄울 "긴장한 작성자" 속마음 pool.
|
||
const REVIEW_NERVOUS_THOUGHTS = [
|
||
'괜찮을까…', '재작업 안 나왔으면', '핵심은 다 들어갔지', '여기 빠뜨린 거 있나', '한 라운드면 통과해줘',
|
||
];
|
||
// 검수자가 keyword 형식으로 던지는 결론 어휘 — 로그 없을 때도 자체 emit.
|
||
const REVIEWER_KEYWORD_BANK = [
|
||
['시나리오 부족', '핵심 누락', '재작업 필요'],
|
||
['승인', '기준 충족', '진행 OK'],
|
||
['일반론', '구체화 필요', '예시 부족'],
|
||
['중복', '간결화', 'over-engineering'],
|
||
['엣지케이스', '경계값', '실패 케이스'],
|
||
['측정 기준', '성공 정의', 'KPI'],
|
||
];
|
||
|
||
// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
|
||
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
|
||
function _innerThoughtTick(){
|
||
// Banter 가 재생 중이면 일반 inner-thought 는 잠시 멈춤 — 동시 다발 풍선으로 산만해지지 않게.
|
||
if(_activeBanter) return;
|
||
// Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
|
||
if(_prevStatus === 'reviewing'){
|
||
const inspectorRole = roleMap['inspector'];
|
||
if(inspectorRole && chars[inspectorRole]){
|
||
// 50% 확률로 검수자가 keyword 던지기.
|
||
if(Math.random() < 0.5){
|
||
const set = REVIEWER_KEYWORD_BANK[Math.floor(Math.random()*REVIEWER_KEYWORD_BANK.length)];
|
||
const txt = set.join(' · ');
|
||
_bubbleFromLog(inspectorRole, txt);
|
||
return;
|
||
}
|
||
}
|
||
// 작성자 측 긴장 표현.
|
||
for(const r of _movedToReviewer.keys()){
|
||
if(Math.random() < 0.7){
|
||
const t = REVIEW_NERVOUS_THOUGHTS[Math.floor(Math.random()*REVIEW_NERVOUS_THOUGHTS.length)];
|
||
_bubbleFromLog(r, t);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// 일반: 작업 중 또는 sit 인 char 중 1명에서 속마음.
|
||
const candidates = Object.keys(chars).filter(k => {
|
||
const a = anim[k]; if(!a) return false;
|
||
return a.mode === 'work' || a.mode === 'sit';
|
||
});
|
||
if(candidates.length === 0) return;
|
||
const working = candidates.filter(k => anim[k].mode === 'work');
|
||
const pool = working.length > 0 ? working : candidates;
|
||
const role = pool[Math.floor(Math.random()*pool.length)];
|
||
const st = stationByKey[role]; if(!st) return;
|
||
const text = _innerThoughtFor(st.agentKey, anim[role].mode);
|
||
if(text) _bubbleFromLog(role, text);
|
||
}
|
||
_managedInterval(_innerThoughtTick, 7500);
|
||
|
||
// ── Webtoon-style 티키타카 banter (refactor: pipeline-aware) ──
|
||
// 각 phase 에 *시퀀스화된 대화 script* 가 있어 phase 진입 시 한 줄씩 시간 차로 emit.
|
||
// 각 line 은 (agentKey, text, emotion) — emotion 은 bubble class 로 변환 (희노애락).
|
||
//
|
||
// 한 phase 마다 여러 variant 중 1개 random pick — 같은 흐름을 매번 안 보게.
|
||
// 진행 중 phase 가 바뀌면 즉시 중단.
|
||
const BANTER_SCRIPTS = {
|
||
// 의도 분석 — CEO 가 planner 와 함께 요청 정리
|
||
analyzing: [
|
||
[
|
||
{ agent: 'ceo', text: '의도 다시 보자.', emotion: 'thought' },
|
||
{ agent: 'planner', text: '핵심은 명확한데…', emotion: 'thought' },
|
||
{ agent: 'ceo', text: '뭐가 막혀?', emotion: 'curious' },
|
||
{ agent: 'planner', text: '성공 기준이 모호해요.', emotion: 'sorrow' },
|
||
{ agent: 'ceo', text: '좋아, 그것부터 정하자.', emotion: 'firm' },
|
||
],
|
||
[
|
||
{ agent: 'planner', text: '범위가 너무 넓은데…', emotion: 'sorrow' },
|
||
{ agent: 'ceo', text: '첫 사용자 한 명만 그려보자.', emotion: 'firm' },
|
||
{ agent: 'planner', text: '오, 그러면 깔끔해지네요.', emotion: 'joy' },
|
||
],
|
||
],
|
||
intake: [
|
||
[
|
||
{ agent: 'ceo', text: '오, 새 요청.', emotion: 'joy' },
|
||
{ agent: 'support', text: '브리프 정리해드릴게요.', emotion: 'thought' },
|
||
{ agent: 'ceo', text: '바로 보자.', emotion: 'firm' },
|
||
],
|
||
],
|
||
// 사용자에게 확인 필요
|
||
need_clarification: [
|
||
[
|
||
{ agent: 'planner', text: '이건 추측으로 가면 위험.', emotion: 'panic' },
|
||
{ agent: 'ceo', text: '맞아. 사장님께 묻자.', emotion: 'firm' },
|
||
{ agent: 'support', text: '질문 정리해서 띄울게요.', emotion: 'thought' },
|
||
],
|
||
],
|
||
// 계획
|
||
planning: [
|
||
[
|
||
{ agent: 'ceo', text: '순서 잡자. 누가 먼저?', emotion: 'firm' },
|
||
{ agent: 'planner', text: '리서치 → 설계 → 구현 흐름.', emotion: 'thought' },
|
||
{ agent: 'developer', text: '설계 전에 데이터 모델 확정', emotion: 'firm' },
|
||
{ agent: 'planner', text: '맞아요, 그게 우선.', emotion: 'joy' },
|
||
],
|
||
[
|
||
{ agent: 'planner', text: '여기 분기점이 두 개.', emotion: 'thought' },
|
||
{ agent: 'ceo', text: 'A 안 비용은?', emotion: 'curious' },
|
||
{ agent: 'planner', text: 'B 보다 절반.', emotion: 'thought' },
|
||
{ agent: 'ceo', text: 'A 로 가자. 확정.', emotion: 'firm' },
|
||
],
|
||
],
|
||
// 구현
|
||
executing: [
|
||
[
|
||
{ agent: 'developer', text: '집중 모드 들어갑니다.', emotion: 'firm' },
|
||
{ agent: 'qa', text: '엣지케이스 같이 봐드려요?', emotion: 'curious' },
|
||
{ agent: 'developer', text: '아, 빈 입력 케이스 빠뜨릴 뻔', emotion: 'panic' },
|
||
{ agent: 'qa', text: '네, 그거 자주 빼먹어요.', emotion: 'thought' },
|
||
{ agent: 'developer', text: '고마워요.', emotion: 'gratitude' },
|
||
],
|
||
[
|
||
{ agent: 'developer', text: '여기 너무 복잡한데.', emotion: 'sorrow' },
|
||
{ agent: 'planner', text: '단계 둘로 나누면?', emotion: 'curious' },
|
||
{ agent: 'developer', text: '오, 그러면 되겠네요.', emotion: 'joy' },
|
||
],
|
||
[
|
||
{ agent: 'designer', text: '여백 좀 더 줄까요?', emotion: 'curious' },
|
||
{ agent: 'developer', text: '여기 컴포넌트 정렬 잘 못맞춰요…', emotion: 'sorrow' },
|
||
{ agent: 'designer', text: 'flex gap 8 로 가면 깔끔.', emotion: 'firm' },
|
||
{ agent: 'developer', text: '오케이 반영.', emotion: 'gratitude' },
|
||
],
|
||
],
|
||
// 검수
|
||
reviewing: [
|
||
[
|
||
{ agent: 'inspector', text: '시나리오 빠진 것 있나…', emotion: 'thought' },
|
||
{ agent: 'planner', text: '괜찮을까…', emotion: 'panic' },
|
||
{ agent: 'inspector', text: '여기 측정 기준 누락.', emotion: 'firm' },
|
||
{ agent: 'planner', text: '아 KPI 부분, 보강할게요.', emotion: 'sorrow' },
|
||
{ agent: 'inspector', text: '그거만 채우면 통과.', emotion: 'thought' },
|
||
],
|
||
[
|
||
{ agent: 'inspector', text: '전체 흐름은 OK.', emotion: 'thought' },
|
||
{ agent: 'inspector', text: '근데 여기 over-engineering.', emotion: 'firm' },
|
||
{ agent: 'developer', text: '단순화 어떻게…', emotion: 'curious' },
|
||
{ agent: 'inspector', text: '함수 셋을 하나로.', emotion: 'firm' },
|
||
{ agent: 'developer', text: '바로 줄일게요.', emotion: 'gratitude' },
|
||
],
|
||
[
|
||
{ agent: 'qa', text: '회귀 케이스 1개 실패.', emotion: 'panic' },
|
||
{ agent: 'developer', text: '어… 어떤 거?', emotion: 'curious' },
|
||
{ agent: 'qa', text: '동시 호출 race.', emotion: 'firm' },
|
||
{ agent: 'developer', text: '아 그거… 죄송, 빨리 고칠게요.', emotion: 'sorrow' },
|
||
],
|
||
],
|
||
// 승인 대기
|
||
waiting_approval: [
|
||
[
|
||
{ agent: 'inspector', text: '위험 작업 감지.', emotion: 'panic' },
|
||
{ agent: 'ceo', text: '사장님 결재 필요.', emotion: 'firm' },
|
||
{ agent: 'support', text: '승인 카드 띄울게요.', emotion: 'thought' },
|
||
],
|
||
],
|
||
error: [
|
||
[
|
||
{ agent: 'developer', text: '앗, 이건 예상 못 했어요.', emotion: 'panic' },
|
||
{ agent: 'qa', text: '재현 단계는?', emotion: 'curious' },
|
||
{ agent: 'developer', text: '지금 정리 중…', emotion: 'sorrow' },
|
||
{ agent: 'inspector', text: '뭐가 깨졌나 같이 보자.', emotion: 'firm' },
|
||
],
|
||
],
|
||
done: [
|
||
[
|
||
{ agent: 'ceo', text: '좋아, 끝났다!', emotion: 'joy' },
|
||
{ agent: 'planner', text: '이번 작업 깔끔하게 완료.', emotion: 'joy' },
|
||
{ agent: 'developer', text: '커피 한 잔!', emotion: 'joy' },
|
||
{ agent: 'support', text: '회의록·일정 자동 정리 했어요.', emotion: 'joy' },
|
||
],
|
||
],
|
||
};
|
||
|
||
// 감정 태그 → CSS class. _emoteBubble 가 .bubble + .bubble-<emotion> 으로 렌더.
|
||
function _emoteBubble(role, text, emotion){
|
||
const ch = chars[role];
|
||
if(!ch || !text) return;
|
||
const b = document.createElement('div');
|
||
b.className = 'bubble bubble-' + (emotion || 'thought');
|
||
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(), 3200);
|
||
}
|
||
|
||
// 활성 banter 추적 — phase 가 바뀌면 즉시 중단해서 새 script 시작.
|
||
let _activeBanter = null;
|
||
function _stopBanter(){
|
||
if(_activeBanter && _activeBanter.timer){ clearTimeout(_activeBanter.timer); }
|
||
_activeBanter = null;
|
||
}
|
||
function _stepBanter(){
|
||
if(!_activeBanter) return;
|
||
if(_activeBanter.idx >= _activeBanter.lines.length){ _activeBanter = null; return; }
|
||
const line = _activeBanter.lines[_activeBanter.idx++];
|
||
const role = roleMap[line.agent];
|
||
if(role){
|
||
_emoteBubble(role, line.text, line.emotion);
|
||
}
|
||
// 다음 줄까지 1.8~2.4초 — 자연스러운 대화 호흡.
|
||
const delay = 1800 + Math.floor(Math.random() * 600);
|
||
_activeBanter.timer = setTimeout(_stepBanter, delay);
|
||
}
|
||
function _playBanterForPhase(phaseOrStatus){
|
||
if(!phaseOrStatus) return;
|
||
const variants = BANTER_SCRIPTS[phaseOrStatus];
|
||
if(!Array.isArray(variants) || variants.length === 0) return;
|
||
const script = variants[Math.floor(Math.random() * variants.length)];
|
||
if(!Array.isArray(script) || script.length === 0) return;
|
||
_stopBanter();
|
||
_activeBanter = { lines: script, idx: 0, timer: null };
|
||
// 첫 줄 즉시.
|
||
_stepBanter();
|
||
}
|
||
// ── A. 상태 계층화 ──
|
||
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
|
||
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
|
||
// critical pivot(error/need_clarification/review_failed/approval/done 등)에서만
|
||
// 캐릭터 시뮬레이션 + 말풍선 강조. 사용자에게 정말 *알릴 만한 변화*에 시각
|
||
// 집중력 몰아주기.
|
||
const CRITICAL_STATUSES = new Set(['need_clarification','waiting_approval','error','done']);
|
||
const CRITICAL_BUBBLE_TYPES = new Set(['warning','error','success']);
|
||
const CRITICAL_TEXT_RE = /버그|오류|실패|승인|중단|완료|❌|🛑|✅|⚠️|보완|재작업|결재/;
|
||
function _isCriticalBubble(b){
|
||
if(!b) return false;
|
||
if(b.type && CRITICAL_BUBBLE_TYPES.has(b.type)) return true;
|
||
if(b.text && CRITICAL_TEXT_RE.test(b.text)) return true;
|
||
return false;
|
||
}
|
||
function routeBubble(b){
|
||
// 일반 status bubble은 무시 — 큐 적체 방지. 사용자에게 의미 있는 신호만 통과.
|
||
if(!_isCriticalBubble(b)) return;
|
||
const role = roleMap[b?.agentId] || 'ceo';
|
||
bubble(role, b?.text || '');
|
||
}
|
||
const STATUS_COPY = {
|
||
idle: { label:'대기 중', note:'새로운 작업 요청을 기다리고 있습니다.', tone:'neutral' },
|
||
intake: { label:'요청 수신', note:'요청을 읽고 작업 범위를 정리하고 있습니다.', tone:'neutral' },
|
||
analyzing: { label:'의도 분석', note:'목표와 제약을 정리하는 중입니다.', tone:'neutral' },
|
||
need_clarification: { label:'확인 필요', note:'결정을 위해 추가 입력이 필요합니다.', tone:'warning' },
|
||
contract_ready: { label:'브리프 확정', note:'요구사항을 실행 가능한 형태로 정리했습니다.', tone:'neutral' },
|
||
planning: { label:'설계 중', note:'실행 순서와 담당을 배치하고 있습니다.', tone:'neutral' },
|
||
executing: { label:'실행 중', note:'담당 에이전트가 실제 작업을 진행 중입니다.', tone:'neutral' },
|
||
reviewing: { label:'검수 중', note:'산출물의 완성도와 리스크를 점검하고 있습니다.', tone:'neutral' },
|
||
waiting_approval: { label:'승인 대기', note:'다음 진행을 위해 사용자의 판단을 기다립니다.', tone:'warning' },
|
||
error: { label:'주의 필요', note:'흐름을 멈춘 이슈를 확인해야 합니다.', tone:'danger' },
|
||
done: { label:'완료', note:'이번 작업 라운드가 정리되었습니다.', tone:'success' },
|
||
};
|
||
function _statusMeta(status){
|
||
return STATUS_COPY[status] || STATUS_COPY.idle;
|
||
}
|
||
function _pct(v){
|
||
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
|
||
}
|
||
let _prevStatus = null;
|
||
// 마지막으로 inspector 가 아니었던 active role — reviewing 진입 시 *작성자* 후보.
|
||
let _lastWriterRole = null;
|
||
let _lastRenderedLog = null;
|
||
function apply(s){
|
||
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
|
||
const st = s?.status || 'idle';
|
||
const meta = _statusMeta(st);
|
||
const officeShell = document.querySelector('.office-shell');
|
||
if(officeShell) officeShell.dataset.status = st;
|
||
// 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
|
||
const statusEl = document.getElementById('status');
|
||
const phasePill = document.getElementById('phasePill');
|
||
if(statusEl) statusEl.textContent = meta.label;
|
||
if(phasePill) phasePill.dataset.tone = meta.tone;
|
||
document.getElementById('agent').textContent = s?.agentName || 'Astra';
|
||
document.getElementById('task').textContent = s?.currentTask || '새 요청을 기다리고 있습니다.';
|
||
document.getElementById('step').textContent = s?.currentStep || meta.label;
|
||
document.getElementById('phaseLabel').textContent = meta.label;
|
||
document.getElementById('phaseNote').textContent = meta.note;
|
||
const lastLog = (s?.recentLogs||[]).slice(-1)[0];
|
||
document.getElementById('log').textContent = lastLog || '아직 기록된 활동이 없습니다.';
|
||
// 작업 중 (executing/reviewing) 상태에 활성 에이전트 머리 위 말풍선 — 로그 변할 때마다.
|
||
if(lastLog && lastLog !== _lastRenderedLog){
|
||
_lastRenderedLog = lastLog;
|
||
if(['executing','reviewing','planning','analyzing'].includes(s?.status||'')){
|
||
// 활성 role 추정: message prefix 또는 status default. 책상이 없으면 무시.
|
||
let r = null;
|
||
const mm2 = (s?.message||'').match(/^([a-z0-9_-]+)/i);
|
||
if(mm2) r = roleMap[mm2[1]];
|
||
if(!r && s?.status === 'reviewing') r = roleMap['inspector'];
|
||
if(!r) r = roleMap['ceo'];
|
||
_bubbleFromLog(r, lastLog);
|
||
}
|
||
}
|
||
const progressPct = _pct(s?.progress);
|
||
document.getElementById('bar').style.width = progressPct + '%';
|
||
document.getElementById('progressLabel').textContent = progressPct + '%';
|
||
const attentionCard = document.getElementById('attentionCard');
|
||
const attentionTitle = document.getElementById('attentionTitle');
|
||
const attentionBody = document.getElementById('attentionBody');
|
||
if(s?.awaitingApproval){
|
||
attentionCard.dataset.tone = 'warning';
|
||
attentionTitle.textContent = '승인 필요';
|
||
attentionBody.textContent = s.awaitingApproval;
|
||
} else if(Array.isArray(s?.needUserInput) && s.needUserInput.length){
|
||
attentionCard.dataset.tone = 'warning';
|
||
attentionTitle.textContent = '추가 입력 필요';
|
||
attentionBody.textContent = s.needUserInput[0];
|
||
} else if(st === 'error'){
|
||
attentionCard.dataset.tone = 'danger';
|
||
attentionTitle.textContent = '이슈 감지';
|
||
attentionBody.textContent = lastLog || meta.note;
|
||
} else {
|
||
attentionCard.dataset.tone = '';
|
||
attentionTitle.textContent = '없음';
|
||
attentionBody.textContent = '현재 막힘 없이 진행 중입니다.';
|
||
}
|
||
// B. 미니 맵 렌더 — pipelineStages가 있을 때만 보임.
|
||
const mm = document.getElementById('miniMap');
|
||
const stages = s?.pipelineStages;
|
||
if(Array.isArray(stages) && stages.length > 0){
|
||
mm.style.display = 'flex';
|
||
mm.innerHTML = '';
|
||
const doneN = stages.filter(x=>x.status==='done').length;
|
||
stages.forEach((stg, i)=>{
|
||
const dot = document.createElement('div');
|
||
dot.className = 'mm-dot';
|
||
dot.dataset.status = stg.status || 'pending';
|
||
const label = document.createElement('span');
|
||
label.className = 'mm-label';
|
||
label.textContent = (i+1) + '. ' + (stg.label || '단계') + ((stg.agentId || stg.agent) ? ' · ' + (stg.agentId || stg.agent) : '');
|
||
dot.appendChild(label);
|
||
mm.appendChild(dot);
|
||
if(i < stages.length - 1){
|
||
const bar = document.createElement('div');
|
||
bar.className = 'mm-bar';
|
||
mm.appendChild(bar);
|
||
}
|
||
});
|
||
const counter = document.createElement('div');
|
||
counter.className = 'mm-counter';
|
||
counter.textContent = doneN + '/' + stages.length;
|
||
mm.appendChild(counter);
|
||
} else {
|
||
mm.style.display = 'none';
|
||
}
|
||
setTimeout(_fitStage, 0);
|
||
// 활성 캐릭터 결정. roleMap 은 agentKey → 실제 존재하는 station.key 로 lookup
|
||
// 하므로, 매핑된 책상이 없으면 null. 사용자가 default ceo 책상을 지워도 안전.
|
||
let role = null;
|
||
const m = (s?.message || '').match(/^([a-z0-9_-]+)/i);
|
||
if(m) role = roleMap[m[1]] || null;
|
||
if(['planning','analyzing','waiting_approval'].includes(st)) role = roleMap['ceo'] || role;
|
||
if(st === 'reviewing') role = roleMap['inspector'] || role;
|
||
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
|
||
activate(role);
|
||
// inspector 가 아닌 active role 이 있으면 *작성자 후보* 로 기억 — 다음 reviewing 진입 시 이 사람이 검수자 옆으로 이동.
|
||
if(role && stationByKey[role] && stationByKey[role].agentKey !== 'inspector'){
|
||
_lastWriterRole = role;
|
||
}
|
||
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
|
||
const isPivot = CRITICAL_STATUSES.has(st);
|
||
const isTransition = st !== _prevStatus;
|
||
// ── Reviewing 진입 / 종료 시 작성자 이동 ──
|
||
// status='reviewing' 진입: 직전 작성자(_lastWriterRole) 캐릭터를 검수자 책상 옆으로 walk.
|
||
// status='reviewing' 종료: 옮겨놓은 작성자들을 모두 원래 자리로 복귀.
|
||
if(isTransition){
|
||
if(st === 'reviewing'){
|
||
const inspectorRole = roleMap['inspector'];
|
||
if(_lastWriterRole && inspectorRole && _lastWriterRole !== inspectorRole){
|
||
_walkToReviewer(_lastWriterRole, inspectorRole);
|
||
}
|
||
} else if(_prevStatus === 'reviewing'){
|
||
// 검수 종료 — 옮겨놓은 모든 char 를 원위치.
|
||
for(const r of Array.from(_movedToReviewer.keys())){
|
||
_restoreWriterHome(r);
|
||
}
|
||
}
|
||
// ── Webtoon-style banter trigger ──
|
||
// Phase 바뀔 때 해당 phase 의 대화 script 한 variant 무작위 선택해서 시퀀스 재생.
|
||
// 진행 중 다른 phase 진입하면 자동 중단되고 새 script 가 시작.
|
||
_playBanterForPhase(st);
|
||
}
|
||
if(isTransition){
|
||
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
|
||
Object.keys(chars).forEach(k => {
|
||
if(k !== role && anim[k].mode === 'work') sendHome(k, 'sit');
|
||
});
|
||
// pivot 상태 진입은 walk + 말풍선 시뮬레이션 트리거.
|
||
if(isPivot && role){
|
||
sendHome(role, st === 'executing' || st === 'reviewing' ? 'work' : 'sit');
|
||
} else if(role){
|
||
// 일반 상태는 *정적 모드만* — 캐릭터를 그 자리에서 mode만 바꿈, 걷지 않음.
|
||
const ch = chars[role];
|
||
if(ch){
|
||
const cx = parseFloat(ch.style.left), cy = parseFloat(ch.style.top);
|
||
const st_ = stationByKey[role];
|
||
const home = st_ ? Math.abs(cx - st_.seatX) < 1 && Math.abs(cy - st_.seatY) < 1 : false;
|
||
if(home){
|
||
// 이미 자리에 있으면 mode만 work/sit으로 토글 (걷기 없음).
|
||
setSprite(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
|
||
} else {
|
||
// 자리를 벗어나 있으면 한 번만 복귀 — 그 이후 같은 status가 연속 와도 다시 보내지 않음.
|
||
sendHome(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
|
||
}
|
||
}
|
||
}
|
||
if(st === 'done'){
|
||
// 완료 — 모두 자리 복귀 후 단체 sit.
|
||
Object.keys(chars).forEach(k => sendHome(k, 'work'));
|
||
const ceoRole = roleMap['ceo']; if(ceoRole) bubble(ceoRole, '완료!');
|
||
setTimeout(() => Object.keys(chars).forEach(k => sendHome(k, 'sit')), 1800);
|
||
}
|
||
}
|
||
_prevStatus = st;
|
||
}
|
||
// D. 캐릭터 컨텍스트 메뉴 — 편집 모드 X일 때만. abort/세부보기/말풍선 정리.
|
||
let _lastState = null;
|
||
function _closeCtxMenu(){
|
||
document.querySelectorAll('.ctx-menu, .ctx-detail').forEach(el => el.remove());
|
||
}
|
||
document.addEventListener('click', e=>{
|
||
// 메뉴 외부 클릭 → 닫기
|
||
if(!e.target.closest('.ctx-menu') && !e.target.closest('.ctx-detail') && !e.target.closest('.char')){
|
||
_closeCtxMenu();
|
||
}
|
||
});
|
||
stage.addEventListener('click', e=>{
|
||
if(_editMode) return; // 편집 모드에선 드래그용 mousedown이 우선
|
||
const ch = e.target.closest('.char');
|
||
if(!ch) return;
|
||
const role = ch.dataset.role;
|
||
if(!role) return;
|
||
e.stopPropagation();
|
||
_closeCtxMenu();
|
||
const menu = document.createElement('div');
|
||
menu.className = 'ctx-menu';
|
||
menu.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
|
||
const head = document.createElement('div');
|
||
head.className = 'ctx-menu-head';
|
||
const st = stationByKey[role] || {label:role};
|
||
head.innerHTML = '<span class="cmh-role">'+ (st.label||role) +'</span><br><span style="color:#D7DBEA;font-size:10px;">' + role + '</span>';
|
||
menu.appendChild(head);
|
||
// 옵션
|
||
const addItem = (label, danger, onClick)=>{
|
||
const it = document.createElement('div');
|
||
it.className = 'ctx-menu-item' + (danger?' danger':'');
|
||
it.textContent = label;
|
||
it.onclick = (ev)=>{ ev.stopPropagation(); onClick(); _closeCtxMenu(); };
|
||
menu.appendChild(it);
|
||
};
|
||
addItem('📋 현재 작업 보기', false, ()=>{
|
||
_showDetail(role, _lastState);
|
||
});
|
||
addItem('💬 말풍선 보내기', false, ()=>{
|
||
const msg = window.prompt(role + '에게 보낼 메시지', '');
|
||
if(msg) bubble(role, msg);
|
||
});
|
||
const sep = document.createElement('div'); sep.className='ctx-menu-divider'; menu.appendChild(sep);
|
||
addItem('🛑 현재 작업 중단', true, ()=>{
|
||
try{ vscode.postMessage({type: 'pixelOfficeCommand', cmd: 'abort', role}); }catch{}
|
||
});
|
||
// 위치 — 클릭한 캐릭터 위
|
||
document.body.appendChild(menu);
|
||
const r = ch.getBoundingClientRect();
|
||
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
||
let mx = r.left + r.width/2 - mw/2, my = r.top - mh - 6;
|
||
if(my < 8) my = r.bottom + 6;
|
||
mx = Math.max(8, Math.min(window.innerWidth - mw - 8, mx));
|
||
menu.style.left = mx + 'px';
|
||
menu.style.top = my + 'px';
|
||
});
|
||
function _showDetail(role, state){
|
||
_closeCtxMenu();
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'ctx-detail';
|
||
const ch = chars[role];
|
||
if(ch) overlay.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
|
||
const st = stationByKey[role] || {label:role};
|
||
const close = document.createElement('button'); close.className='cd-close'; close.textContent='✕'; close.onclick=_closeCtxMenu;
|
||
overlay.appendChild(close);
|
||
const h = document.createElement('h3'); h.textContent = (st.label||role) + ' · ' + role;
|
||
overlay.appendChild(h);
|
||
const dl = document.createElement('dl');
|
||
const add = (k,v)=>{ if(v==null||v==='') return; const dt=document.createElement('dt');dt.textContent=k;const dd=document.createElement('dd');dd.textContent=v;dl.appendChild(dt);dl.appendChild(dd); };
|
||
add('상태', state?.status);
|
||
add('단계', state?.currentStep);
|
||
add('다음', state?.nextStep);
|
||
add('메모', state?.message);
|
||
if(state?.requirementContract){
|
||
const c = state.requirementContract;
|
||
add('목표', c.goal);
|
||
add('맥락', c.context);
|
||
add('형식', c.format);
|
||
if(Array.isArray(c.criteria) && c.criteria.length) add('기준', c.criteria.join(' · '));
|
||
if(Array.isArray(c.openQuestions) && c.openQuestions.length) add('미해결 질문', c.openQuestions.join(' / '));
|
||
}
|
||
overlay.appendChild(dl);
|
||
if(Array.isArray(state?.recentLogs) && state.recentLogs.length){
|
||
const logs = document.createElement('div'); logs.className='cd-logs';
|
||
logs.innerHTML = state.recentLogs.map(l=>'<div>'+ (l||'').replace(/[<>]/g,'') +'</div>').join('');
|
||
overlay.appendChild(logs);
|
||
}
|
||
document.body.appendChild(overlay);
|
||
const ow=overlay.offsetWidth, oh=overlay.offsetHeight;
|
||
overlay.style.left = Math.max(20, (window.innerWidth-ow)/2) + 'px';
|
||
overlay.style.top = Math.max(40, (window.innerHeight-oh)/2) + 'px';
|
||
}
|
||
|
||
// E. Activity ticker state — 최근 N개 행동만 ring buffer로. 너무 길어지면
|
||
// 자동으로 가장 오래된 것 drop.
|
||
const _tickerItems = [];
|
||
const TICKER_MAX = 12;
|
||
function _classifyTickerItem(text){
|
||
if(/❌|fail|error|⚠️/i.test(text)) return 'tk-err';
|
||
if(/✅|ok|created|edited|completed/i.test(text)) return 'tk-ok';
|
||
if(/⚠|warn|hollow/i.test(text)) return 'tk-warn';
|
||
return '';
|
||
}
|
||
function _renderTicker(){
|
||
const wrap = document.getElementById('ticker');
|
||
const track = document.getElementById('tickerTrack');
|
||
if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
|
||
wrap.style.display = 'block';
|
||
const html = _tickerItems.map(it => {
|
||
const cls = _classifyTickerItem(it.text);
|
||
const ag = it.agentId ? '<span class="tk-agent">'+ it.agentId +'</span>' : '';
|
||
return '<span class="tk-item '+ cls +'">'+ ag + (it.text || '').replace(/[<>]/g,'') +'</span>';
|
||
}).join('');
|
||
track.innerHTML = html;
|
||
}
|
||
// ─────────────── Dual-mode message handler (refactor #D) ───────────────
|
||
// 옛 pixelOfficeUpdate/Activity 와 새 officeSnapshot 둘 다 listen. 첫 officeSnapshot
|
||
// 이 도착하면 _seenOfficeSnapshot = true 가 되고 옛 message 는 무시됨 → 안전한 점진 전환.
|
||
let _seenOfficeSnapshot = false;
|
||
|
||
// OfficeSnapshot → 옛 AgentWorkState 형태로 변환 후 기존 apply() 재사용.
|
||
// 정보 손실 최소화: roster[activeId] 에서 status/step/log 가져옴.
|
||
function _phaseToStatus(phase){
|
||
if(phase === 'awaiting-approval') return 'waiting_approval';
|
||
if(phase === 'reporting') return 'done';
|
||
if(phase === 'intake') return 'analyzing';
|
||
return phase || 'idle';
|
||
}
|
||
const ROLE_LABELS = {
|
||
ceo:'CEO',
|
||
planner:'기획',
|
||
researcher:'리서치',
|
||
designer:'디자인',
|
||
developer:'개발',
|
||
qa:'QA',
|
||
inspector:'감리',
|
||
support:'지원',
|
||
writer:'문서',
|
||
};
|
||
const ROLE_SHORT = {
|
||
ceo:'CEO',
|
||
planner:'PL',
|
||
researcher:'RS',
|
||
designer:'UX',
|
||
developer:'DEV',
|
||
qa:'QA',
|
||
inspector:'PO',
|
||
support:'PM',
|
||
writer:'WR',
|
||
};
|
||
function _renderRoster(roster, activeAgentId){
|
||
const wrap = document.getElementById('rosterList');
|
||
const count = document.getElementById('rosterCount');
|
||
if(!wrap || !count) return;
|
||
const list = Array.isArray(roster) ? roster : [];
|
||
const officeShell = document.querySelector('.office-shell');
|
||
if(officeShell) officeShell.dataset.rosterEmpty = list.length ? 'false' : 'true';
|
||
count.textContent = String(list.length);
|
||
if(!list.length){
|
||
wrap.innerHTML = '<div class="roster-item"><div class="roster-copy"><strong>등록된 팀이 없습니다</strong><span class="roster-meta">회사 모드를 켜면 라인업이 표시됩니다.</span></div></div>';
|
||
return;
|
||
}
|
||
wrap.innerHTML = list.map((r)=>{
|
||
const active = r.agentId === activeAgentId;
|
||
const role = ROLE_LABELS[r.roleCategory] || r.roleCategory || '지원';
|
||
const short = ROLE_SHORT[r.roleCategory] || 'AG';
|
||
return '<div class="roster-item '+(active?'active':'')+'" data-agent="'+(r.agentId||'')+'">'+
|
||
'<div class="roster-avatar">'+short+'</div>'+
|
||
'<div class="roster-copy">'+
|
||
'<strong>'+(r.agentName || r.agentId || 'Agent')+'</strong>'+
|
||
'<div class="roster-meta"><span class="roster-status"></span><span>'+role+'</span></div>'+
|
||
'</div>'+
|
||
'</div>';
|
||
}).join('');
|
||
wrap.querySelectorAll('.roster-item[data-agent]').forEach((item)=>{
|
||
const id = item.dataset.agent;
|
||
item.addEventListener('mouseenter', ()=>_previewAgent(id, true, item));
|
||
item.addEventListener('mouseleave', ()=>_previewAgent(id, false, item));
|
||
});
|
||
}
|
||
function _previewAgent(agentId, on, item){
|
||
if(item) item.classList.toggle('preview', !!on);
|
||
const st = findStationByAgent(agentId);
|
||
if(!st) return;
|
||
const desk = __deskWrap[st.key];
|
||
const ch = chars[st.key];
|
||
if(desk) desk.classList.toggle('preview', !!on);
|
||
if(ch) ch.classList.toggle('preview', !!on);
|
||
}
|
||
// refactor #G-full — roster 에 있는 agent 중 desk 가 없는 경우 자동 생성.
|
||
function _roleCategoryToCharRow(role){
|
||
switch(role){
|
||
case 'ceo': return 0;
|
||
case 'planner': return 1;
|
||
case 'researcher': return 2;
|
||
case 'designer': return 3;
|
||
case 'developer': return 4;
|
||
case 'qa': return 5;
|
||
case 'inspector': return 6;
|
||
case 'support': return 7;
|
||
case 'writer': return 1;
|
||
default: return 0;
|
||
}
|
||
}
|
||
function _autoCreateDeskForAgent(r){
|
||
// 기존 _addNewDesk 와 같은 메커니즘이지만 agentKey/label/charRow/sprite 를 roster 정보에 맞춰 채움.
|
||
const id = 'desk_'+(__nextDeskN++);
|
||
// 위치: 현재 station 수에 따라 격자 배치. 너무 빽빽하면 겹치지만 사용자가 드래그로 정리 가능.
|
||
const slotN = stations.length;
|
||
const cols = 6;
|
||
const baseX = 36 + (slotN % cols) * 112;
|
||
const baseY = 28 + Math.floor(slotN / cols) * 95;
|
||
const isBoss = r.roleCategory === 'ceo';
|
||
const st = {
|
||
key: id,
|
||
agentKey: r.agentId,
|
||
label: r.agentName || r.agentId,
|
||
charRow: _roleCategoryToCharRow(r.roleCategory),
|
||
deskSprite: isBoss ? 'astra-desk-exec' : 'astra-desk-work',
|
||
face: 'R',
|
||
boss: isBoss,
|
||
deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
|
||
seatX: baseX + 4, seatY: baseY + 36,
|
||
dock: [baseX + 32, baseY + 80],
|
||
roam: [[baseX - 20, baseY + 120],[baseX + 60, baseY + 100]],
|
||
};
|
||
stations.push(st);
|
||
_rebuildStationIndex();
|
||
buildStation(st);
|
||
}
|
||
function _ensureRosterDesks(roster){
|
||
if(!Array.isArray(roster)) return;
|
||
for(const r of roster){
|
||
if(!r || !r.agentId) continue;
|
||
if(_autoDeskedFor.has(r.agentId)) continue; // 이미 한번 처리됨 (생성했든 매핑됐든)
|
||
// 직접 매핑된 station 있는가?
|
||
const direct = stations.find(s => s.agentKey === r.agentId);
|
||
if(direct){ _autoDeskedFor.add(r.agentId); continue; }
|
||
// alias 로 잡히는가? (writer→planner 등 옛 이름)
|
||
const aliased = AGENT_ALIASES[r.agentId];
|
||
if(aliased && stations.find(s => s.agentKey === aliased)){
|
||
_autoDeskedFor.add(r.agentId);
|
||
continue;
|
||
}
|
||
// 아무 데도 없음 — 자동 생성.
|
||
_autoCreateDeskForAgent(r);
|
||
_autoDeskedFor.add(r.agentId);
|
||
}
|
||
}
|
||
|
||
function applyFromSnapshot(snap){
|
||
if(!snap) return;
|
||
_latestSnapshot = snap;
|
||
const roster = Array.isArray(snap.roster) ? snap.roster : [];
|
||
const orderedRoster = _pipelineRosterOrder(roster, snap.pipeline);
|
||
_renderRoster(orderedRoster, snap.activeAgentId);
|
||
_syncOfficeLayout(snap.pipeline, orderedRoster);
|
||
if(_layoutSource!=='roster' || _hasSavedLayout) _ensureRosterDesks(orderedRoster);
|
||
_renderPipelineDecor(snap.pipeline, orderedRoster);
|
||
const active = (snap.activeAgentId && roster.find(a => a.agentId === snap.activeAgentId)) || roster[0];
|
||
const activeStage = snap.pipeline && Array.isArray(snap.pipeline.stages)
|
||
? snap.pipeline.stages.find(stg => stg.status === 'active')
|
||
: null;
|
||
const stageCount = snap.pipeline && Array.isArray(snap.pipeline.stages) ? snap.pipeline.stages.length : 0;
|
||
const doneCount = snap.pipeline && Array.isArray(snap.pipeline.stages)
|
||
? snap.pipeline.stages.filter(stg => stg.status === 'done').length
|
||
: 0;
|
||
const synthetic = {
|
||
agentId: snap.activeAgentId || (active && active.agentId) || 'main',
|
||
agentName: (active && active.agentName) || 'Agent',
|
||
status: (active && active.status) || _phaseToStatus(snap.phase),
|
||
currentTask: snap.task && snap.task.goal,
|
||
currentStep: (active && active.currentStep) || (activeStage && activeStage.label),
|
||
// apply() 의 message regex 가 첫 토큰을 agentId 로 추출 — activeAgentId 그대로 넘김.
|
||
message: snap.activeAgentId || '',
|
||
recentLogs: (active && active.lastLog) ? [active.lastLog] : [],
|
||
progress: stageCount ? (doneCount / stageCount) : 0,
|
||
pipelineStages: snap.pipeline && snap.pipeline.stages,
|
||
needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined,
|
||
awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined,
|
||
requirementContract: snap.task,
|
||
updatedAt: snap.updatedAt,
|
||
};
|
||
apply(synthetic);
|
||
// 풍선 시드 — routeBubble 의 critical filter 그대로 통과.
|
||
if(Array.isArray(snap.newBubbles)){
|
||
snap.newBubbles.forEach(b => routeBubble({ agentId: b.agentId, text: b.text, type: b.type }));
|
||
}
|
||
// Activity — snapshot 이 full ring buffer 를 통째로 보내므로 ticker 를 매번 재구성.
|
||
if(Array.isArray(snap.activity)){
|
||
_tickerItems.length = 0;
|
||
const tail = snap.activity.slice(-TICKER_MAX);
|
||
for(const it of tail){
|
||
_tickerItems.push({ agentId: it.agentId, text: it.text });
|
||
}
|
||
_renderTicker();
|
||
// 가장 최근 activity 항목 한 개에 대해 머리 위 말풍선 (옛 동작 흉내).
|
||
const last = tail[tail.length - 1];
|
||
if(last){
|
||
const r = roleMap[last.agentId];
|
||
if(r) _bubbleFromLog(r, last.text);
|
||
}
|
||
}
|
||
}
|
||
|
||
window.addEventListener('message',e=>{
|
||
const d = e.data;
|
||
if(!d) return;
|
||
if(d.type === 'officeSnapshot'){
|
||
_seenOfficeSnapshot = true;
|
||
applyFromSnapshot(d.value);
|
||
return;
|
||
}
|
||
// 옛 path — 새 snapshot 을 받기 시작했다면 무시 (백엔드가 양쪽 다 emit 중인 마이그레이션 기간).
|
||
if(_seenOfficeSnapshot) return;
|
||
if(d.type === 'pixelOfficeUpdate'){
|
||
const v = d.value;
|
||
if(!v) return;
|
||
if(v.state) apply(v.state);
|
||
if(Array.isArray(v.bubbles)) v.bubbles.forEach(routeBubble);
|
||
} else if(d.type === 'pixelOfficeActivity'){
|
||
const v = d.value;
|
||
if(!v || !Array.isArray(v.items)) return;
|
||
for(const it of v.items){
|
||
_tickerItems.push(it);
|
||
if(_tickerItems.length > TICKER_MAX) _tickerItems.shift();
|
||
// 작업중 캐릭터 머리 위 말풍선 — 감리는 키워드, 그 외는 짧은 한 줄.
|
||
const r = roleMap[it.agentId];
|
||
if(r) _bubbleFromLog(r, it.text);
|
||
}
|
||
_renderTicker();
|
||
}
|
||
});
|
||
try{vscode.postMessage({type:'getPixelOfficeState'})}catch{}
|
||
|
||
// ─────────────── Layout Editor ───────────────
|
||
// 사용자가 ✏️ 편집 버튼을 누르면 편집 모드 진입. 책상/캐릭터/소품을 드래그로
|
||
// 위치 조정 가능. 4px snap. 저장 시 백엔드 workspace state에 영구 저장. 다음
|
||
// 패널 오픈 때 자동 복원. 디폴트로 복귀 버튼도 제공.
|
||
let _editMode=false, _drag=null, _dragDX=0, _dragDY=0, _snapshotBeforeEdit=null, _selected=null;
|
||
|
||
function _snapshotLayout(){
|
||
// 새 schema v2: cells 가 단순 좌표 패치가 아니라 stations 전체 정의를 담는다.
|
||
// (책상 추가/제거가 가능하니 default 와의 delta 로는 표현 불가.) v1 호환을 위해
|
||
// _restoreLayout 이 양쪽 모두 처리.
|
||
return {
|
||
schema: 2,
|
||
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 || 'astra-desk-work',
|
||
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,
|
||
x: parseFloat(el.style.left),
|
||
y: parseFloat(el.style.top),
|
||
w: el.dataset.objW ? parseFloat(el.dataset.objW) : undefined,
|
||
rot: parseFloat(el.dataset.rot || '0'),
|
||
z: parseFloat(el.dataset.z || '0'),
|
||
})),
|
||
};
|
||
}
|
||
|
||
// stage 에 그려진 모든 desk/char/obj DOM 제거 (벽 창문 제외). 레이아웃 리빌드 직전에 호출.
|
||
function _clearStage(){
|
||
Array.from(stage.querySelectorAll('.desk,.char,img.obj')).forEach(el=>el.remove());
|
||
Object.keys(__deskWrap).forEach(k=>delete __deskWrap[k]);
|
||
Object.keys(chars).forEach(k=>delete chars[k]);
|
||
Object.keys(anim).forEach(k=>delete anim[k]);
|
||
stations = [];
|
||
// refactor #G-full: layout reset 시 auto-desk 결정 기록도 초기화 →
|
||
// 다음 snapshot 도착 시 roster 기반으로 다시 평가.
|
||
_autoDeskedFor.clear();
|
||
_clearPipelineDecor();
|
||
}
|
||
|
||
function _applyRot(el, rot){
|
||
if(!el) return;
|
||
const r = typeof rot==='number' ? rot : 0;
|
||
el.dataset.rot = String(r);
|
||
el.style.transform = r === 0 ? '' : ('rotate('+r+'deg)');
|
||
}
|
||
function _applyZ(el, z){
|
||
if(!el) return;
|
||
if(typeof z !== 'number') return;
|
||
el.dataset.z = String(z);
|
||
el.style.zIndex = z === 0 ? '' : String(z);
|
||
}
|
||
// v2 schema 또는 cell 에 desk 정의 필드가 있으면 stage 를 통째로 재구축.
|
||
// v1 (옛 포맷) 이면 좌표만 패치하는 in-place 갱신.
|
||
function _isV2Snap(snap){
|
||
if(!snap || !Array.isArray(snap.cells)) return false;
|
||
if(snap.schema === 2) return true;
|
||
return snap.cells.some(c => c && (typeof c.deskSprite === 'string' || typeof c.agentKey === 'string' || typeof c.charRow === 'number'));
|
||
}
|
||
function _restoreLayout(snap){
|
||
if(!snap) return;
|
||
if(_isV2Snap(snap)){
|
||
_clearStage();
|
||
(snap.cells||[]).forEach(c=>{
|
||
const st = {
|
||
key: c.roleKey,
|
||
agentKey: c.agentKey || '',
|
||
label: c.label || c.roleKey,
|
||
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
|
||
deskSprite: c.deskSprite || 'astra-desk-work',
|
||
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,
|
||
seatX: c.seatX, seatY: c.seatY,
|
||
};
|
||
stations.push(st);
|
||
const m = String(st.key).match(/^desk_(\\d+)$/);
|
||
if(m){ const n = parseInt(m[1],10); if(n >= __nextDeskN) __nextDeskN = n + 1; }
|
||
});
|
||
_rebuildStationIndex();
|
||
stations.forEach(buildStation);
|
||
(snap.cells||[]).forEach(c=>{
|
||
const wrap=__deskWrap[c.roleKey], ch=chars[c.roleKey];
|
||
if(wrap){ _applyRot(wrap, c.deskRot); _applyZ(wrap, c.deskZ); }
|
||
if(ch){ _applyRot(ch, c.charRot); _applyZ(ch, c.charZ); }
|
||
});
|
||
(snap.objs||[]).forEach(o=>{
|
||
const el = addImg(o.name, o.x, o.y, o.w);
|
||
if(o.id){ el.dataset.objId = o.id; }
|
||
_applyRot(el, o.rot); _applyZ(el, o.z);
|
||
});
|
||
return;
|
||
}
|
||
// v1 — 옛 포맷, in-place patch.
|
||
(snap.cells||[]).forEach(c=>{
|
||
const wrap=__deskWrap[c.roleKey];
|
||
if(wrap){
|
||
if(typeof c.deskX==='number') wrap.style.left=c.deskX+'px';
|
||
if(typeof c.deskY==='number') wrap.style.top=c.deskY+'px';
|
||
if(typeof c.deskW==='number') wrap.style.width=c.deskW+'px';
|
||
_applyRot(wrap, c.deskRot);
|
||
_applyZ(wrap, c.deskZ);
|
||
}
|
||
const ch=chars[c.roleKey];
|
||
if(ch){
|
||
if(typeof c.seatX==='number'){ch.style.left=c.seatX+'px';ch.dataset.homeX=c.seatX;}
|
||
if(typeof c.seatY==='number'){ch.style.top=c.seatY+'px';ch.dataset.homeY=c.seatY;}
|
||
_applyRot(ch, c.charRot);
|
||
_applyZ(ch, c.charZ);
|
||
}
|
||
const st=stationByKey[c.roleKey];
|
||
if(st){
|
||
if(typeof c.deskX==='number') st.deskX=c.deskX;
|
||
if(typeof c.deskY==='number') st.deskY=c.deskY;
|
||
if(typeof c.seatX==='number') st.seatX=c.seatX;
|
||
if(typeof c.seatY==='number') st.seatY=c.seatY;
|
||
}
|
||
});
|
||
(snap.objs||[]).forEach(o=>{
|
||
let el=null;
|
||
if(o.id) el=stage.querySelector('img.obj[data-obj-id="'+o.id+'"]');
|
||
if(!el && o.name){
|
||
// id 없으면 name으로 첫번째 매칭 — legacy 데이터 호환.
|
||
el=stage.querySelector('img.obj[data-obj-name="'+o.name+'"]');
|
||
}
|
||
if(!el) return;
|
||
if(typeof o.x==='number') el.style.left=o.x+'px';
|
||
if(typeof o.y==='number') el.style.top=o.y+'px';
|
||
if(typeof o.w==='number'){ el.style.width=o.w+'px'; el.dataset.objW=String(o.w); }
|
||
_applyRot(el, o.rot);
|
||
_applyZ(el, o.z);
|
||
});
|
||
}
|
||
|
||
function _setEdit(on){
|
||
_editMode=!!on;
|
||
document.body.dataset.editMode = _editMode?'true':'false';
|
||
document.getElementById('editToolbar').style.display = _editMode?'flex':'none';
|
||
document.getElementById('editBtn').textContent = _editMode?'편집 종료':'Customize';
|
||
if(_editMode){
|
||
_snapshotBeforeEdit = _snapshotLayout();
|
||
} else {
|
||
_snapshotBeforeEdit = null;
|
||
// 편집 종료 시 selected 강조 해제.
|
||
if(_selected){ _selected.classList.remove('selected'); _selected = null; }
|
||
}
|
||
_onSelectionChanged();
|
||
}
|
||
|
||
// ── 선택 변화 시 호출 — 속성 패널 / 삭제 버튼 상태 sync ──
|
||
function _onSelectionChanged(){
|
||
const panel = document.getElementById('propPanel');
|
||
const delBtn = document.getElementById('deleteSelBtn');
|
||
if(!_editMode || !_selected){
|
||
panel.classList.remove('show'); panel.innerHTML='';
|
||
if(delBtn) delBtn.disabled = true;
|
||
return;
|
||
}
|
||
// 캐릭터를 선택해도 그 책상의 속성을 편집한다 (캐릭터와 책상은 한 쌍).
|
||
let targetForProps = _selected;
|
||
if(targetForProps.classList.contains('char')){
|
||
const role = Object.keys(chars).find(k=>chars[k]===targetForProps);
|
||
if(role && __deskWrap[role]) targetForProps = __deskWrap[role];
|
||
}
|
||
if(targetForProps.classList.contains('desk')){
|
||
if(delBtn) delBtn.disabled = false;
|
||
_renderDeskProps(targetForProps);
|
||
} else if(_selected.classList.contains('obj')){
|
||
if(delBtn) delBtn.disabled = false;
|
||
_renderObjProps(_selected);
|
||
} else {
|
||
panel.classList.remove('show'); panel.innerHTML='';
|
||
if(delBtn) delBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
function _renderDeskProps(deskEl){
|
||
const role = deskEl.dataset.role;
|
||
const st = stationByKey[role]; if(!st) return;
|
||
const panel = document.getElementById('propPanel');
|
||
panel.classList.add('show');
|
||
// 에이전트 매핑 dropdown.
|
||
const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
|
||
// 책상 sprite picker.
|
||
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
|
||
// charRow 썸네일 picker (idle-r<n>-f0.png).
|
||
let thumbs='';
|
||
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,'"')+'"></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>'+
|
||
'<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;
|
||
const lbl = deskEl.querySelector('.label'); if(lbl) lbl.textContent = st.label;
|
||
};
|
||
panel.querySelector('#ppAgent').onchange = (ev)=>{
|
||
st.agentKey = ev.target.value || '';
|
||
// CSS data-role 색깔은 agentKey 기준 — 매핑 변경 시 swap.
|
||
if(st.agentKey){ deskEl.dataset.agent = st.agentKey; if(chars[role]) chars[role].dataset.agent = st.agentKey; }
|
||
else { delete deskEl.dataset.agent; if(chars[role]) delete chars[role].dataset.agent; }
|
||
};
|
||
panel.querySelector('#ppDesk').onchange = (ev)=>{
|
||
st.deskSprite = ev.target.value;
|
||
const img = deskEl.querySelector('img'); if(img) img.src = png(st.deskSprite);
|
||
};
|
||
panel.querySelectorAll('.pp-thumb').forEach(t=>{
|
||
t.onclick = ()=>{
|
||
const r = parseInt(t.dataset.charrow,10);
|
||
st.charRow = r;
|
||
if(anim[role]){ anim[role].row = r; }
|
||
const ch = chars[role]; if(ch){ ch.dataset.row = r; const img=ch.querySelector('img'); if(img) img.src = png('idle-r'+r+'-f0'); }
|
||
panel.querySelectorAll('.pp-thumb').forEach(x=>x.classList.toggle('active', x===t));
|
||
};
|
||
});
|
||
panel.querySelector('#ppFace').onchange = (ev)=>{
|
||
st.face = ev.target.value;
|
||
if(anim[role]) anim[role].face = st.face;
|
||
// 현재 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){
|
||
const panel = document.getElementById('propPanel');
|
||
panel.classList.add('show');
|
||
const name = el.dataset.objName || '';
|
||
const w = el.dataset.objW || '';
|
||
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
|
||
panel.innerHTML =
|
||
'<h4>가구 속성</h4>'+
|
||
'<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
|
||
'<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
|
||
panel.querySelector('#ppObjName').onchange = (ev)=>{
|
||
el.dataset.objName = ev.target.value;
|
||
el.src = png(ev.target.value);
|
||
};
|
||
panel.querySelector('#ppObjW').oninput = (ev)=>{
|
||
const v = ev.target.value.trim();
|
||
if(v === ''){ el.style.removeProperty('width'); delete el.dataset.objW; }
|
||
else { el.style.width = parseFloat(v)+'px'; el.dataset.objW = String(parseFloat(v)); }
|
||
};
|
||
}
|
||
|
||
// ── 신규 책상 추가 ──
|
||
function _addNewDesk(){
|
||
const id = 'desk_'+(__nextDeskN++);
|
||
// 기본 위치: stage 중앙 근처, 다른 책상과 안 겹치게 살짝 오프셋.
|
||
const baseX = 280 + ((__nextDeskN%5)*16);
|
||
const baseY = 260 + ((__nextDeskN%5)*16);
|
||
const st = {
|
||
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'astra-desk-work', face: 'R',
|
||
boss: false,
|
||
deskX: baseX, deskY: baseY, deskW: 112,
|
||
seatX: baseX+4, seatY: baseY+36,
|
||
dock: [baseX+32, baseY+80],
|
||
roam: [[baseX-20, baseY+120],[baseX+60, baseY+100]],
|
||
};
|
||
stations.push(st); _rebuildStationIndex();
|
||
buildStation(st);
|
||
// 새 책상을 자동 선택.
|
||
if(_selected) _selected.classList.remove('selected');
|
||
_selected = __deskWrap[id]; _selected.classList.add('selected');
|
||
_onSelectionChanged();
|
||
}
|
||
|
||
// ── 신규 프랍 추가 — sprite picker 모달 ──
|
||
function _openPropPicker(){
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'prop-picker';
|
||
const box = document.createElement('div');
|
||
box.className = 'prop-picker-box';
|
||
box.innerHTML = '<h3>가구 추가</h3>'+
|
||
'<div class="prop-picker-grid">'+
|
||
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+_spriteLabel(n)+'</div></div>').join('')+
|
||
'</div>';
|
||
overlay.appendChild(box);
|
||
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
|
||
box.querySelectorAll('.prop-pick').forEach(p=>{
|
||
p.onclick = ()=>{
|
||
const name = p.dataset.name;
|
||
// stage 중앙 근처에 배치.
|
||
const x = 300 + ((__nextObjN%4)*12);
|
||
const y = 280 + ((__nextObjN%4)*12);
|
||
const el = addImg(name, x, y);
|
||
overlay.remove();
|
||
// 자동 선택.
|
||
if(_selected) _selected.classList.remove('selected');
|
||
_selected = el; _selected.classList.add('selected');
|
||
_onSelectionChanged();
|
||
};
|
||
});
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ── 선택 항목 삭제 ──
|
||
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
|
||
// · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
|
||
// · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
|
||
// · 프랍 선택 → 프랍 삭제.
|
||
function _deleteSelected(){
|
||
if(!_editMode || !_selected) return;
|
||
if(_selected.classList.contains('char')){
|
||
// VS Code webview 는 window.confirm() 을 지원하지 않아 (silently false 반환)
|
||
// 이전 코드에서 if(!confirm(...)) return 패턴이 즉시 return 되어 삭제가 안 됐다.
|
||
// 편집 세션은 Cancel 버튼으로 되돌릴 수 있으니 확인 다이얼로그 없이 바로 삭제.
|
||
let role = Object.keys(chars).find(k=>chars[k]===_selected);
|
||
if(!role) role = _selected.dataset.role || '';
|
||
const st = role ? stationByKey[role] : null;
|
||
_selected.remove();
|
||
if(role){ delete chars[role]; delete anim[role]; if(st) st.noChar = true; }
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
if(_selected.classList.contains('desk')){
|
||
const role = _selected.dataset.role;
|
||
_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();
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
if(_selected.classList.contains('obj')){
|
||
_selected.remove();
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
}
|
||
|
||
function _findDraggable(el){
|
||
while(el && el !== stage){
|
||
if(el.classList){
|
||
if(el.classList.contains('obj')) return el;
|
||
if(el.classList.contains('char')) return el;
|
||
if(el.classList.contains('desk')) return el;
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
stage.addEventListener('mousedown', e=>{
|
||
if(!_editMode) return;
|
||
const target = _findDraggable(e.target);
|
||
// 빈 공간 클릭 → 선택 해제.
|
||
if(!target){
|
||
if(_selected){ _selected.classList.remove('selected'); _selected=null; _onSelectionChanged(); }
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
// 선택 표시 갱신 — 한 번에 하나만 selected. 회전 키가 이 객체에 적용됨.
|
||
if(_selected && _selected !== target) _selected.classList.remove('selected');
|
||
_selected = target;
|
||
target.classList.add('selected');
|
||
_onSelectionChanged();
|
||
_drag = target;
|
||
const rect = stage.getBoundingClientRect();
|
||
const tx = parseFloat(target.style.left)||0;
|
||
const ty = parseFloat(target.style.top)||0;
|
||
_dragDX = ((e.clientX - rect.left) / _stageScale) - tx;
|
||
_dragDY = ((e.clientY - rect.top) / _stageScale) - ty;
|
||
target.classList.add('dragging');
|
||
});
|
||
|
||
// 키보드 — 편집 모드에서 선택된 객체에 적용.
|
||
// R : 시계방향 90도 회전
|
||
// Shift+R : 반시계방향 90도 회전
|
||
// ] : 레이어 위로 (z-index +1)
|
||
// [ : 레이어 아래로 (z-index -1)
|
||
function _bringLayer(el, delta){
|
||
const cur = parseFloat(el.dataset.z || '0');
|
||
const next = Math.max(-99, Math.min(99, cur + delta));
|
||
el.dataset.z = String(next);
|
||
el.style.zIndex = String(next);
|
||
}
|
||
document.addEventListener('keydown', e=>{
|
||
if(!_editMode || !_selected) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||
const key = e.key.toLowerCase();
|
||
if(key === 'r'){
|
||
e.preventDefault();
|
||
const delta = e.shiftKey ? -90 : 90;
|
||
const cur = parseFloat(_selected.dataset.rot || '0');
|
||
const next = ((cur + delta) % 360 + 360) % 360;
|
||
_selected.dataset.rot = String(next);
|
||
_selected.style.transform = next === 0 ? '' : ('rotate(' + next + 'deg)');
|
||
return;
|
||
}
|
||
if(key === ']'){ e.preventDefault(); _bringLayer(_selected, +1); return; }
|
||
if(key === '['){ e.preventDefault(); _bringLayer(_selected, -1); return; }
|
||
});
|
||
document.addEventListener('mousemove', e=>{
|
||
if(!_editMode || !_drag) return;
|
||
const rect = stage.getBoundingClientRect();
|
||
let x = ((e.clientX - rect.left) / _stageScale) - _dragDX;
|
||
let y = ((e.clientY - rect.top) / _stageScale) - _dragDY;
|
||
// 4px 격자 snap
|
||
x = Math.round(x/4)*4;
|
||
y = Math.round(y/4)*4;
|
||
// stage 경계 안에 가두기
|
||
const sw = stage.offsetWidth, sh = stage.offsetHeight;
|
||
const tw = _drag.offsetWidth || 0, th = _drag.offsetHeight || 0;
|
||
x = Math.max(0, Math.min(sw - tw, x));
|
||
y = Math.max(0, Math.min(sh - th, y));
|
||
_drag.style.left = x+'px';
|
||
_drag.style.top = y+'px';
|
||
});
|
||
document.addEventListener('mouseup', ()=>{
|
||
if(_drag){ _drag.classList.remove('dragging'); }
|
||
if(_editMode && _drag && _drag.classList){
|
||
if(_drag.classList.contains('desk')){
|
||
// 책상만 새 좌표 저장. 캐릭터는 *그대로 둔다* (분리).
|
||
const role = _drag.dataset.role;
|
||
const st = stationByKey[role];
|
||
if(st){
|
||
st.deskX = parseFloat(_drag.style.left);
|
||
st.deskY = parseFloat(_drag.style.top);
|
||
}
|
||
} else if(_drag.classList.contains('char')){
|
||
// 캐릭터를 옮긴 경우 — 그 자리를 새 home으로.
|
||
const role = Object.keys(chars).find(k=>chars[k]===_drag);
|
||
if(role){
|
||
const st = stationByKey[role];
|
||
const nx = parseFloat(_drag.style.left), ny = parseFloat(_drag.style.top);
|
||
if(st){ st.seatX = nx; st.seatY = ny; }
|
||
_drag.dataset.homeX = nx;
|
||
_drag.dataset.homeY = ny;
|
||
}
|
||
}
|
||
}
|
||
_drag = null;
|
||
});
|
||
|
||
document.getElementById('editBtn').addEventListener('click', ()=>{
|
||
_setEdit(!_editMode);
|
||
});
|
||
document.getElementById('layerUpBtn').addEventListener('click', ()=>{
|
||
if(_selected) _bringLayer(_selected, +1);
|
||
});
|
||
document.getElementById('layerDownBtn').addEventListener('click', ()=>{
|
||
if(_selected) _bringLayer(_selected, -1);
|
||
});
|
||
document.getElementById('saveBtn').addEventListener('click', ()=>{
|
||
const layout = _snapshotLayout();
|
||
try{ vscode.postMessage({type:'savePixelOfficeLayout', value: layout}); }catch{}
|
||
_setEdit(false);
|
||
});
|
||
document.getElementById('cancelBtn').addEventListener('click', ()=>{
|
||
// 편집 시작 시 찍어둔 스냅샷으로 되돌리기.
|
||
if(_snapshotBeforeEdit) _restoreLayout(_snapshotBeforeEdit);
|
||
_setEdit(false);
|
||
});
|
||
document.getElementById('resetBtn').addEventListener('click', ()=>{
|
||
// window.confirm 은 VS Code webview 에서 동작 안 함 — 그냥 reset 트리거.
|
||
// 사용자 안전망: 편집모드 외에서 reset 누르면 reload 되니, 저장된 layout 만 날아가고 직전 편집 분은 영향 없음.
|
||
try{ vscode.postMessage({type:'resetPixelOfficeLayout'}); }catch{}
|
||
});
|
||
document.getElementById('addDeskBtn').addEventListener('click', _addNewDesk);
|
||
document.getElementById('addPropBtn').addEventListener('click', _openPropPicker);
|
||
document.getElementById('deleteSelBtn').addEventListener('click', _deleteSelected);
|
||
// Delete / Backspace 키도 같은 동작 — 단 입력 필드 포커스 시 무시.
|
||
document.addEventListener('keydown', e=>{
|
||
if(!_editMode) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
if(e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); _deleteSelected(); }
|
||
});
|
||
|
||
// 저장된 layout 로드 요청 + 응답 처리.
|
||
window.addEventListener('message', e=>{
|
||
const d = e.data;
|
||
if(!d || typeof d !== 'object') return;
|
||
if(d.type === 'pixelOfficeLayoutLoaded'){
|
||
const saved = d.value && !_looksLikeLegacyShowroomLayout(d.value) ? d.value : null;
|
||
_hasSavedLayout = !!saved;
|
||
if(saved) {
|
||
_layoutSource = 'saved';
|
||
_restoreLayout(saved);
|
||
} else if(_latestSnapshot) {
|
||
_syncOfficeLayout(_latestSnapshot.pipeline, _pipelineRosterOrder(_latestSnapshot.roster || [], _latestSnapshot.pipeline));
|
||
_renderPipelineDecor(_latestSnapshot.pipeline, _latestSnapshot.roster || []);
|
||
}
|
||
}
|
||
if(d.type === 'pixelOfficeLayoutSaved'){
|
||
if(d.value && !d.value.reset) {
|
||
_hasSavedLayout = true;
|
||
_layoutSource = 'saved';
|
||
}
|
||
if(d.value && d.value.reset){
|
||
// 디폴트로 리셋된 경우 — 페이지를 재로딩해서 코드 기본값으로 복귀.
|
||
location.reload();
|
||
}
|
||
}
|
||
});
|
||
try{ vscode.postMessage({type:'getPixelOfficeLayout'}); }catch{}
|
||
|
||
`;
|
||
|
||
export function officeRuntimeJs(derivedBase: string): string {
|
||
// 추출본은 이미 `<script>(function(){` 로 시작 (원본 line 4002). closing 만 붙임.
|
||
return OFFICE_RUNTIME_JS_TEMPLATE.replace('${assets.derivedBase}', derivedBase) + '})();<\/script>';
|
||
}
|