# AI 꾸러미 — 상대 상태 보여 주기 (생각 중 · 나감) — Presence (online status)
> 상대가 보내는 숨결(0.8초마다)과 상태 신호로 「보는 중 · 생각 중 · 자리 비움 · 나감」을 점과 글로 보여 줘, 상대가 사라졌는지 기다리는지 알게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i443

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 상대 상태 보여 주기 (생각 중 · 나감) — Presence (online status)

#### 1. 목표
온라인 대전 방에 상대 상태 표시를 넣어 줘 — 보는 중(초록) · 생각 중(노랑, 상대가 보는 칸에 옅은 표시) · 자리 비움(회색) · 나감(빨강)을 상대 카드의 점과 글로. 창을 닫으면 서버가 끊김을 알아채 「상대 나감」. 상대 카드 위 점.

#### 2. 핵심 기술 용어
- **Presence (online status)** — 상대 상태 — 접속 · 활동 여부
- **Heartbeat** — 숨결 — 살아 있다는 짧은 신호를 주기적으로
- **onDisconnect (Firebase)** — 끊기면 서버가 대신 「나감」을 적는 예약
- **Visibility API (document.hidden)** — 탭이 숨었는지 — 자리 비움 판단

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 상태 네 가지 — 보는 중 #7dffb2 · 생각 중 #ffd36b · 자리 비움 #8d97b8 · 나감 #ff5d6c, 점 + 글로
- 연결된 동안 0.8초마다 숨결, 끊기면 서버가 「나감」을 대신 알림
- 살아 있는 상태(보는 중 · 생각 중)는 점이 숨 쉬듯 깜빡
- 「상대가 보는 칸 보여 주기」 켜고 끄기

#### 5. 완성 기준 (이게 보이면 성공)
- 상대가 생각 중이면 점이 노랗게 깜빡이고 빈칸 위에 옅은 표시가 돌아다닌다
- 자리 비움이면 회색, 「상대 나가기」를 누르면 지연 뒤 서버의 「끊김 감지」를 거쳐 빨간 「나감」
- 메시지 흐름 그림에 숨결 ♥ 가 0.8초마다 보이다 나가면 멈춘다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 상대 기기는 상태가 바뀔 때마다 서버에 알린다 — 생각 중(판을 보며 고민) · 자리 비움(탭 숨김) · 다시 보는 중.
- 연결된 동안 0.8초마다 숨결(♥)을 보내, 서버가 「살아 있다」를 안다.
- 창을 닫으면 알릴 틈이 없다 — 서버가 연결이 끊긴 것을 알아채(Firebase onDisconnect 처럼) 「나감」을 대신 알린다.
- 내 화면은 받은 상태를 점 색 · 글로 보여 주고, 생각 중일 땐 상대가 보는 칸에 옅은 표시.

## 핵심 코드 — 상태 알리기 · 숨결 · 끊기면 서버가 「나감」
(발췌: 새로 씀 (demos/demosMusicNet.ts demoPresence() 의 tell · 숨결 0.8초 · leave 흐름))
```ts
type Pres = 'watch' | 'think' | 'away' | 'gone';
const PRES: Record<Pres, { label: string; col: string }> = {
  watch: { label: '보는 중', col: '#7dffb2' },
  think: { label: '생각 중', col: '#ffd36b' },
  away: { label: '자리 비움', col: '#8d97b8' },
  gone: { label: '나감', col: '#ff5d6c' },
};

// 내 상태 알리기 (바뀔 때만)
let mine: Pres = 'watch';
const tell = (p: Pres): void => {
  if (p === mine) return;
  mine = p;
  send({ type: 'presence', p });
};
document.addEventListener('visibilitychange', () => tell(document.hidden ? 'away' : 'watch'));
board.addEventListener('pointermove', () => tell('think')); // 판 위에서 고민 중

// 숨결 — 연결된 동안 0.8초마다
const beat = setInterval(() => send({ type: 'beat' }), 800);

// Firebase 실시간 DB 라면 끊김은 서버가 대신 적는다:
// onDisconnect(ref(db, 'rooms/' + id + '/presence/' + me)).set('gone');

// 상대 상태 받기 → 카드의 점 색 · 글
function onPresence(p: Pres): void {
  dot.style.background = PRES[p].col;
  label.textContent = PRES[p].label;
}
```

## 흔한 실수 · 확인 목록
- [ ] **창을 닫을 때 「나감」을 보내려 하면 대부분 못 보낸다** — 서버 쪽 끊김 감지(onDisconnect · 숨결이 끊긴 지 몇 초)로 대신 알린다.
- [ ] **마우스가 움직일 때마다 상태를 보내면 메시지가 폭주한다** — 상태가 실제로 바뀔 때만 보내고, 칸 위치는 0.5초에 한 번쯤으로 줄인다.
- [ ] **「나감」을 바로 판 끝으로 처리하면 잠깐 끊긴 사람이 진다** — 나감 뒤 몇 초는 다시 접속(i440)을 기다린다.

## 완성 기준 체크리스트
- [ ] 상대가 생각 중이면 점이 노랗게 깜빡이고 빈칸 위에 옅은 표시가 돌아다닌다
- [ ] 자리 비움이면 회색, 「상대 나가기」를 누르면 지연 뒤 서버의 「끊김 감지」를 거쳐 빨간 「나감」
- [ ] 메시지 흐름 그림에 숨결 ♥ 가 0.8초마다 보이다 나가면 멈춘다

## 이 기술 정보
- id: `i443` · 분류: 게임 시스템 · AI › 온라인 대전 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 0.8초마다 작은 숨결 하나 + 상태가 바뀔 때만 알림.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i443
- 쓰면 좋을 때: 온라인 대전에서 상대가 안 둘 때 기다릴지 나갈지 몰라 답답할 때 / 방에서 누가 준비됐는지 보여야 할 때
- 쓰지 말 때: 개인 정보 — 상대가 보는 칸까지 보여 주는 것은 게임에 따라 「훔쳐보기」가 될 수 있다 (켜고 끄기) / 숨결을 너무 자주(매 프레임) — 서버 요금 · 배터리

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoPresence — `src/demos/demosMusicNet.ts:2355`
```ts
function demoPresence(): Made {
  const net = new Net();
  const lat = { v: 200 };
  let showHover = true;
  const me = new Board();
  let seen: Pres = 'watch';
  let real: Pres = 'watch';
  let alive = true;
  let lastBeat = 0;
  let goneAt = -1;
  let hoverTarget = -1;
  let hoverMove = 0;
  const tell = (p: Pres, label: string): void => {
    real = p;
    const L = lat.v / 1000;
    net.send(2, 1, L, label, PRES[p].col, () => {
      net.send(1, 0, L, label, PRES[p].col, () => (seen = p));
    });
  };
  const leave = (): void => {
    if (!alive) return;
    alive = false;
    real = 'gone';
    net.mark(2, '창 닫음', BAD_COL);
    net.spans.push({ lane: 2, t0: net.now, t1: net.now + 2.2, col: BAD_COL });
    // 서버가 끊김을 알아챔 (Firebase onDisconnect 처럼)
    net.after(lat.v / 1000 + 0.6, () => {
      net.mark(1, '끊김 감지', BAD_COL);
      net.send(1, 0, lat.v / 1000, '상대 나감', BAD_COL, () => {
        seen = 'gone';
        goneAt = net.now;
      });
    });
  };
  const run = (): void => {
    net.restart();
    me.clear();
    me.set(4, 'X', false, -9);
    me.set(0, 'O', false, -9);
    me.set(8, 'X', false, -9);
    alive = true;
    goneAt = -1;
    if (seen !== 'watch' || real !== 'watch') {
      net.mark(2, '다시 들어옴', OK_COL);
      tell('watch', '들어옴');
    }
    real = 'watch';
    net.after(1.2, () => tell('think', '생각 중…'));
    net.after(3.4, () => {
      const L = lat.v / 1000;
      real = 'watch';
      net.send(2, 1, L, 'O → ③', OPP_COL, () => net.send(1, 0, L, 'O → ③', OPP_COL, () => {
        me.set(2, 'O', false, net.now);
        seen = 'watch';
      }));
    });
    net.after(4.8, () => tell('away', '자리 비움'));
    net.after(6.4, leave);
    net.after(9.2, run);
  };
  run();
  return {
    controls: [
      latControl(lat),
      { type: 'toggle', label: '상대가 보는 칸 보여 주기', value: true, on: (v) => (showHover = v) },
      { type: 'button', label: '상대 나가기', on: leave },
      { type: 'button', label: '처음부터', on: () => run() },
    ],
    draw(g, w, h, _t, dt) {
      reset(g);
      net.step(dt);
      const dd = Math.min(dt, 0.1);
      // 숨결(하트비트): 연결된 동안 0.8초마다
      if (alive && net.now - lastBeat > 0.8) {
        lastBeat = net.now;
        net.send(2, 1, lat.v / 1000, '♥', '#ff9db8');
      }
      // 생각 중: 마우스가 칸 위를 돌아다님
      hoverMove -= dd;
      if (seen === 'think') {
        if (hoverMove <= 0) {
          const empty = [1, 2, 3, 5, 6, 7];
          hoverTarget = empty[Math.floor(Math.random() * empty.length)]!;
          hoverMove = 0.55;
        }
        me.hover = showHover ? hoverTarget : -1;
      } else me.hover = -1;
      const L = nlay(w, h);
      const { u, big } = L;
      netBg(g, w, h, u);
      // 상대 카드
      const cx = L.pad;
      const cy = L.pad;
      const cw = L.colW - L.pad;
      const ch = (big ? 34 : 22) * u;
      panel(g, cx, cy, cw, ch, 5 * u);
      const st = PRES[seen];
      const ar = ch * 0.32;
      const ax = cx + ar + 5 * u;
      const ay = cy + ch / 2;
      g.globalAlpha = seen === 'gone' ? 0.35 : seen === 'away' ? 0.6 : 1;
      g.fillStyle = hexA(OPP_COL, 0.25);
      g.beginPath();
      g.arc(ax, ay, ar, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = OPP_COL;
      g.beginPath();
      g.arc(ax, ay - ar * 0.2, ar * 0.38, 0, Math.PI * 2);
      g.fill();
      g.beginPath();
      g.arc(ax, ay + ar * 0.85, ar * 0.65, Math.PI, 0);
      g.fill();
      g.globalAlpha = 1;
      // 상태 점
      const pulse = seen === 'watch' || seen === 'think' ? 0.5 + 0.5 * Math.sin(net.now * 5) : 0;
      g.fillStyle = hexA(st.col, 0.3 * pulse);
      g.beginPath();
      g.arc(ax + ar * 0.75, ay + ar * 0.7, ar * 0.5, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = st.col;
      g.strokeStyle = '#0b1222';
      g.lineWidth = 1.5 * u;
      g.beginPath();
      g.arc(ax + ar * 0.75, ay + ar * 0.7, ar * 0.3, 0, Math.PI * 2);
      g.fill();
      g.stroke();
      const nx = ax + ar + 6 * u;
      txt(g, '상대 · 민지', nx, ay - ch * 0.17, (big ? 9 : 6.6) * u, '#e6ebff', 'left', 800);
      let line = st.label;
      if (seen === 'think') line += ' ' + '•••'.slice(0, 1 + (Math.floor(net.now * 3) % 3));
      if (seen === 'gone' && goneAt > 0) line += ` · ${Math.max(0, Math.ceil(20 - (net.now - goneAt) * 6))}초 뒤 기권승`;
      txt(g, line, nx, ay + ch * 0.2, (big ? 8 : 6) * u, st.col, 'left', 700);
      // 판
      const by = cy + ch + (big ? 10 : 6) * u;
      const bs = Math.min(L.colW - L.pad, h - by - L.pad - (big ? 18 : 10) * u);
      const bx = L.pad + (L.colW - L.pad - bs) / 2;
      drawBoard(g, bx, by, bs, me, net.now, u * 0.85);
      if (seen === 'gone') {
        rr(g, bx, by, bs, bs, 6 * u);
        g.fillStyle = 'rgba(6,8,16,0.55)';
        g.fill();
        txt(g, '상대가 나갔어요', bx + bs / 2, by + bs / 2, (big ? 10 : 7) * u, '#ffb0b8', 'center', 800);
      }
      const leg = (big ? 7.4 : 5.4) * u;
      let lx = L.pad;
      const ly = by + bs + (big ? 6 : 3) * u;
      for (const k of ['watch', 'think', 'away', 'gone'] as Pres[]) {
        g.fillStyle = PRES[k].col;
        g.globalAlpha = k === seen ? 1 : 0.4;
        g.beginPath();
        g.arc(lx + leg * 0.4, ly + leg * 0.6, leg * 0.35, 0, Math.PI * 2);
        g.fill();
        g.font = font(leg, 700);
        txt(g, PRES[k].label, lx + leg, ly + leg * 0.62, leg, PRES[k].col, 'left', 700);
        lx += leg * 1.4 + g.measureText(PRES[k].label).width + 3 * u;
        g.globalAlpha = 1;
      }
      void real;
      drawSeq(g, L.sx, L.sy, L.sw, L.sh, net, u, big);
    },
  };
}
```

### i443 견본 항목 — `src/demos/demosMusicNet.ts:2529`
```ts
  i443: { kind: '2d', caption: '상대의 숨결(♥) · 상태 신호로 보는 중 · 생각 중 · 자리 비움 · 나감을 점과 글로', make: demoPresence }
```

## 관련 기술
- 먼저 알면 좋은 기술: [다시 접속 (판 이어 하기)](https://ai-techstudio.web.app/ai/t/i440.md) `i440` · [동시에 둔 수 정리 (순서 정하기)](https://ai-techstudio.web.app/ai/t/i442.md) `i442`
- 다음에 해 볼 기술: [지연 숨기기 (먼저 보여 주고 확인)](https://ai-techstudio.web.app/ai/t/i439.md) `i439`
- 참고 문서: [MDN — Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)
