Files
connectai/src/features/astraOffice/view/runtime.ts
T
2026-05-18 08:15:01 +09:00

1980 lines
89 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 자동 분리: 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';
}
setInterval(()=>{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)
setInterval(()=>{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));
}
setInterval(()=>{
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);
}
setInterval(_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,'&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>'+
'<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>';
}