Update ConnectAI codebase
This commit is contained in:
@@ -7,7 +7,7 @@ const OFFICE_RUNTIME_JS_TEMPLATE = `
|
||||
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-v2.png\")');
|
||||
officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v3.png\")');
|
||||
officeEl.classList.add('has-art');
|
||||
}
|
||||
let _stageScale = 1;
|
||||
@@ -41,9 +41,20 @@ const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',busi
|
||||
// \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=['desk-main','desk-boss','desk-dark-mirror','desk-main-mirror','desk-dark','desk-boss-mirror','desk-main-front','desk-dark-front','desk-boss-front','desk-partition'];
|
||||
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=['board','plant-tall','bookshelf','plant-bushy','partition','cooler','filing','couch','rug','shelf','printer','monitor-blue','monitor-black','chair-blue','crt'];
|
||||
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=[];
|
||||
@@ -63,6 +74,7 @@ const anim={}; // role \u2192 animation state.
|
||||
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]);
|
||||
@@ -113,8 +125,8 @@ function _pipelineRosterOrder(roster, pipeline){
|
||||
return _uniqueAgentsById(ordered);
|
||||
}
|
||||
function _stationSpriteFor(slot, roleCategory){
|
||||
if(roleCategory==='ceo') return 'desk-boss';
|
||||
return slot % 2 ? 'desk-dark' : 'desk-main';
|
||||
if(roleCategory==='ceo') return 'astra-desk-exec';
|
||||
return 'astra-desk-work';
|
||||
}
|
||||
function _stationSlots(count){
|
||||
const rows=[];
|
||||
@@ -131,7 +143,7 @@ function _stationSlots(count){
|
||||
const remaining = count - row*cols;
|
||||
const inRow = Math.min(cols, remaining);
|
||||
const xs = xSets[inRow] || xSets[3];
|
||||
const rowYs = rowCount === 1 ? [330] : (rowCount === 2 ? [304,432] : [270,380,490]);
|
||||
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']));
|
||||
}
|
||||
@@ -141,12 +153,12 @@ 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:'desk-boss',deskX:294,deskY:188,deskW:124,seatX:319,seatY:218,face:'R',
|
||||
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=104;
|
||||
const deskW=112;
|
||||
const seatX=deskX+4;
|
||||
const seatY=deskY+36;
|
||||
return {
|
||||
@@ -677,6 +689,8 @@ const REVIEWER_KEYWORD_BANK = [
|
||||
// 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'];
|
||||
@@ -712,6 +726,171 @@ function _innerThoughtTick(){
|
||||
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 등)는 *정적 갱신*만 하고,
|
||||
@@ -871,6 +1050,10 @@ function apply(s){
|
||||
_restoreWriterHome(r);
|
||||
}
|
||||
}
|
||||
// ── Webtoon-style banter trigger ──
|
||||
// Phase 바뀔 때 해당 phase 의 대화 script 한 variant 무작위 선택해서 시퀀스 재생.
|
||||
// 진행 중 다른 phase 진입하면 자동 중단되고 새 script 가 시작.
|
||||
_playBanterForPhase(st);
|
||||
}
|
||||
if(isTransition){
|
||||
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
|
||||
@@ -1123,7 +1306,7 @@ function _autoCreateDeskForAgent(r){
|
||||
agentKey: r.agentId,
|
||||
label: r.agentName || r.agentId,
|
||||
charRow: _roleCategoryToCharRow(r.roleCategory),
|
||||
deskSprite: isBoss ? 'desk-boss' : 'desk-main',
|
||||
deskSprite: isBoss ? 'astra-desk-exec' : 'astra-desk-work',
|
||||
face: 'R',
|
||||
boss: isBoss,
|
||||
deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
|
||||
@@ -1259,7 +1442,7 @@ function _snapshotLayout(){
|
||||
agentKey: st.agentKey || '',
|
||||
label: st.label || '',
|
||||
charRow: st.charRow ?? 0,
|
||||
deskSprite: st.deskSprite || 'desk-main',
|
||||
deskSprite: st.deskSprite || 'astra-desk-work',
|
||||
face: st.face || 'R',
|
||||
boss: !!st.boss,
|
||||
noChar: !!st.noChar,
|
||||
@@ -1330,7 +1513,7 @@ function _restoreLayout(snap){
|
||||
agentKey: c.agentKey || '',
|
||||
label: c.label || c.roleKey,
|
||||
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
|
||||
deskSprite: c.deskSprite || 'desk-main',
|
||||
deskSprite: c.deskSprite || 'astra-desk-work',
|
||||
face: c.face || 'R',
|
||||
boss: !!c.boss,
|
||||
noChar: !!c.noChar,
|
||||
@@ -1448,7 +1631,7 @@ function _renderDeskProps(deskEl){
|
||||
// 에이전트 매핑 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':'')+'>'+s+'</option>').join('');
|
||||
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++){
|
||||
@@ -1513,9 +1696,9 @@ function _renderObjProps(el){
|
||||
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':'')+'>'+s+'</option>').join('');
|
||||
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
|
||||
panel.innerHTML =
|
||||
'<h4>프랍 속성</h4>'+
|
||||
'<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)=>{
|
||||
@@ -1536,7 +1719,7 @@ function _addNewDesk(){
|
||||
const baseX = 280 + ((__nextDeskN%5)*16);
|
||||
const baseY = 260 + ((__nextDeskN%5)*16);
|
||||
const st = {
|
||||
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'desk-main', face: 'R',
|
||||
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,
|
||||
@@ -1557,9 +1740,9 @@ function _openPropPicker(){
|
||||
overlay.className = 'prop-picker';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'prop-picker-box';
|
||||
box.innerHTML = '<h3>프랍 추가 — sprite 선택</h3>'+
|
||||
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">'+n+'</div></div>').join('')+
|
||||
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(); };
|
||||
|
||||
Reference in New Issue
Block a user