본문으로 건너뛰기

사람 움직이기 ​

사람을 움직이는 방법은 세 가지다. 한 씬에서 섞어 써도 된다.

방법어떻게언제
루틴오브젝트에 behavior: { routine } 를 준다. 엔진이 자리를 골라 걸어가 앉고 일하고 쉰다살아 있는 사무실·공장 배경
명령앱이 command(id, …) 로 "저기로 가라", "3번 의자에 앉아라", "나가라" 를 준다출입 기록·호출·회의 같은 실제 이벤트를 보여 줄 때
씬 데이터오브젝트 position·pose 를 바꾼다. 바뀐 사이를 걷기·앉기로 잇는다 (길 찾기 없이 곧게)편집기, 드문 순간 이동

루틴과 명령은 같은 엔진(createBehaviorWorld)이 계획한다. 그래서 명령받은 사람도 가구·벽을 돌아가고, 루틴대로 걷는 사람과 서로 비켜 가며, 앉을 때 책상·등받이를 뚫지 않는다.

루틴 ​

json
{ "id": "kim", "asset": "isora/people/office-worker", "position": [3, 4, 0.1], "rotation": 0,
  "behavior": { "routine": "office", "desk": "chair-3" } }
  • routine: 기본 루틴 id 또는 씬 routines 에 정의한 id. desk: 일할 때 먼저 앉는 의자. 다른 사람은 이 자리를 쓰지 않는다.

  • 기본 루틴 (DEFAULT_ROUTINES)

    id이름하는 일
    office사무직일하기 30~60초 ↔ 물·쉬기·돌아다니기
    focused집중형오래 일하고 가끔 출력·물
    relaxed느긋한짧게 일하고 오래 쉬기·회의
    meeting회의 많음회의 ↔ 일하기
    factory현장 작업설비 조작 ↔ 쉬기
    visitor방문객돌아다니기·쉬기·자료 보기
    office-day출퇴근 사무직09:00 출근, 12~13시 점심 외출, 18:00 퇴근 (씬 clock 이 있을 때)
    factory-shift교대 근무08:00~17:00, 점심에 쉬기
    manual직접 제어명령이 올 때까지 제자리

    루틴 이름은 영어가 기본이고 한국어는 names.ko 에 있다 (routineName(routine, 'ko')).

  • 새 루틴: 단계(steps)마다 활동 가중치와 머무는 시간(초)을 적는다. 시간표(schedule)는 씬에 clock 이 있을 때 켜진다.

ts
scene.clock = { start: '08:50', minutesPerSecond: 2 }   // 0초 = 08:50, 1초에 2분
scene.routines = [{
  id: 'support',
  name: '고객 지원',
  steps: [
    { activities: { work: 3, meet: 1 }, duration: [40, 80] },
    { activities: { drink: 1, rest: 1 }, duration: [10, 20] },
  ],
  schedule: [
    { from: '18:00', to: '09:00', away: true },                                   // 문으로 나가 퇴근
    { from: '12:00', to: '13:00', steps: [{ activities: { rest: 1 }, duration: [60, 60] }] },
  ],
}]

재생: <IsoScene playBehaviors behaviorSpeed={2} />, 또는 아래처럼 앱이 useBehaviorPlayback 을 쥐고 behavior 로 넘긴다.

명령 ​

준비 ​

앱이 행동 재생을 쥐고 <IsoScene> 에 넘긴다. 같은 재생을 앱과 화면이 함께 보므로, 앱이 준 명령이 바로 화면에 나온다.

tsx
import { IsoScene, useBehaviorPlayback } from '@isora/react'

function Floor({ scene, registry }) {
  // behavior 가 없는 사람도 control 에 적으면 명령으로 움직인다 (명령이 없으면 제자리).
  const behavior = useBehaviorPlayback(scene, registry, { control: ['visitor-1'] })

  useEffect(() => {
    // 출입 기록이 오면 그 사람을 자기 자리로 보낸다.
    return badgeFeed.subscribe(({ personId, deskId }) => {
      behavior.command(personId, { type: 'do', spot: deskId })
    })
  }, [behavior.command])

  return <IsoScene scene={scene} registry={registry} behavior={behavior} showActivities />
}
  • behavior.command(id, commands) 는 지금 재생 시각에 명령한다. 없는 사람이면 null 이다.
  • 명령을 받을 수 있는 사람: behavior 가 있는 사람 + control 에 적은 사람. 씬에서 behavior: { routine: 'manual' } 로 적어도 된다.

명령 종류 ​

명령하는 일
{ type: 'go', to: [x, y] }그 자리로 걸어가 선다. to 는 사람 발자국(0.5×0.5)의 모서리다 — 가운데를 알면 [cx − 0.25, cy − 0.25]. 설 수 없는 곳이면 가장 가까운 설 수 있는 곳, 누가 서 있으면 둘레의 빈 곳 (여럿을 한 곳으로 보내면 둘러선다)
{ type: 'go', to, activity: 'drink' }가서 그 일을 하며 선다 — 자리가 없어도 서서 커피를 마신다 (말풍선·동작 포함)
{ type: 'do', spot: 'chair-3' }그 가구·설비의 빈자리에서 그 자리의 활동을 한다. spot 은 가구 id 나 자리 id(chair-3#seat0)
{ type: 'do', activity: 'drink' }그 활동의 빈자리 중 가까운 곳으로 가서 한다
{ type: 'do', activity: 'sleep' }빈 침대 자리로 가서 눕는다 (침대 옆이나 발치로 올라간다)
{ type: 'do', activity: 'rest', spot: 'sofa-1' }그 가구의 자리에서 그 일을 한다. 자리의 원래 활동과 달라도 된다 — { spot: 'chair-3', activity: 'rest' } 은 책상에서 졸기
{ type: 'idle' } · { type: 'idle', activity: 'drink' }그 자리에 멈춘다 (앉아 있으면 앉은 채). 지나갈 사람이 있으면 비켜 선다. activity 를 주면 그 일을 하며 있는다
{ type: 'leave' }가까운 문으로 나가 씬에서 빠진다 (hidden). 다음 명령이 오면 같은 문으로 들어와서 한다
{ type: 'routine' }명령을 그만두고 루틴으로 돌아간다 (manual 루틴이면 제자리)
  • 모든 명령(routine 빼고)에 duration(초)을 줄 수 있다.

  • go·do 에 orElse 를 주면 그 명령을 할 수 없을 때(빈자리 없음·길 없음) 대신 한다. 이어서 적을 수 있다.

    ts
    // 침대에서 잔다. 침대가 다 찼으면 소파에서, 소파도 찼으면 제 책상에서 존다.
    { type: 'do', activity: 'sleep', orElse: { type: 'do', activity: 'rest', orElse: { type: 'do', spot: 'chair-3', activity: 'rest' } } }
    // 탕비실 빈자리에서 마신다. 다 찼으면 냉장고 앞에 둘러서서 마신다.
    { type: 'do', activity: 'drink', orElse: { type: 'go', to: [15.5, 2.6], activity: 'drink' } }
  • 명령은 배열로 이어서 줄 수 있다. 앞 명령이 끝나면 다음 명령을 한다.

    • duration 을 빼면: 뒤에 명령이 있으면 도착하자마자 다음으로 넘어가고, 마지막 명령이면 다음 명령이 올 때까지 이어 한다.
ts
// 순찰: 세 곳을 돌고 정수기에서 5초 쉰 뒤 루틴으로
behavior.command('guard-1', [
  { type: 'go', to: [2, 9] },
  { type: 'go', to: [14, 9] },
  { type: 'go', to: [14, 2] },
  { type: 'do', activity: 'drink', duration: 5 },
  { type: 'routine' },
])

// 회의 소집: 세 사람을 가까운 회의 자리(탁자 앞 의자)로. 탁자 자체에는 자리가 없고 둘러싼 의자가 meet 자리다.
for (const id of ['kim', 'lee', 'park']) behavior.command(id, { type: 'do', activity: 'meet' })

// 퇴근과 출근
behavior.command('kim', { type: 'leave' })
behavior.command('kim', { type: 'do', spot: 'chair-3' })   // 나갔던 문으로 들어와 자리로

결과 ​

ts
const result = behavior.command('kim', { type: 'do', spot: 'chair-3' })
// { start: 12.4, arrive: 19.8, end: Infinity, ok: true }
필드뜻
start명령을 받아 움직이기 시작한 시각. 앉거나 일어서는 도중이었으면 그 동작이 끝난 시각
arrive첫 명령의 자리에 닿아 그 일을 하기 시작하는 시각 (앉는 자리면 다 앉은 뒤)
end첫 명령이 끝나는 시각. 다음 명령까지 이어 하면 Infinity
ok첫 명령을 — orElse 까지 — 할 수 없어 그 자리에 멈춰 섰으면 false — 모르는 자리, 빈자리 없음, 길 없음

데이터로 움직이기: useActorCommands ​

서버 상태·센서 값처럼 계속 바뀌는 데이터를 사람 움직임으로 옮길 때는, 사람마다 지금 해야 할 일을 선언한다. 값이 바뀐 사람에게만 명령이 가고, 같은 값이면 다시 보내지 않는다 (렌더마다 새 객체를 만들어도 된다).

tsx
import { useActorCommands, useBehaviorPlayback } from '@isora/react'

const behavior = useBehaviorPlayback(scene, registry, { control: agents.map((a) => a.id) })
useActorCommands(
  behavior,
  Object.fromEntries(
    agents.map((agent) => [
      agent.id,
      agent.busy
        ? { type: 'do', spot: agent.chair }                                    // 일하는 중: 제 책상
        : { type: 'do', activity: 'rest', orElse: { type: 'do', spot: agent.chair, activity: 'rest' } },
    ]),
  ),
)
  • 목록에서 빠진 사람은 마지막 명령을 이어 한다. 다시 들어오면 새로 보낸다.
  • 돌려주는 값은 사람 id → 마지막으로 보낸 명령의 결과(CommandResult)다.
  • 실제 예제: example/claude-office — 이 PC 의 Claude Code 세션을 사무실 사람으로 보여 준다.

명령을 받은 순간 ​

  • 하던 일을 그 시각에 끊는다. 걷던 중이면 그 자리에서 새 길로, 앉아 있으면 일어선 뒤 간다.
  • 앉거나 일어서는 도중이면 그 동작을 마친 뒤 따른다 (반쯤 앉은 채 돌아서지 않는다).
  • 가던 자리는 내놓는다. 곧바로 다른 사람이 그 자리를 쓸 수 있다.
  • 다른 사람의 "내 자리"(desk)라도 명령이면 앉는다. 누가 앉아 있거나 가는 중인 자리는 안 된다 (ok: false).
  • 이미 그린 시각보다 앞으로는 명령하지 않는다. 더 이른 시각을 주면 그린 시각으로 당긴다.

코어만 쓰기 (React 없이) ​

ts
import { createBehaviorWorld } from '@isora/core'

const world = createBehaviorWorld(scene, registry, { seed: 1, control: ['guest'] })
world.frameAt(10)                                              // 10초까지 계획
world.command('guest', { type: 'go', to: [1, 5] }, 10)         // 10초에 명령
const frame = world.frameAt(15)
frame.overrides.guest.position   // 15초의 자리
frame.people.guest               // { activity: 'idle', phase: 'walk', controlled: true, … }

서버에서 시뮬레이션하거나 스냅숏 테스트를 만들 때 쓴다. 같은 명령을 같은 at 에 주면 언제 계산해도 같다.

읽기: 누가 어디서 무엇을 ​

useBehaviorPlayback() 이 돌려주는 값 (틱마다 바뀐다):

필드뜻
people[id]{ activity, phase, spot?, until, controlled? } — 하는 일, 단계, 쓰는 자리, 끝나는 시각, 명령을 따르는 중인지
overrides[id]이 틱의 position·rotation·pose·seat
hidden퇴근·외출로 씬 밖에 있는 사람
states사람이 아닌 것의 상태 (빈 책상 모니터 off, 문 open)
ids움직이는 사람 id
time재생 시각 (초)
world계획 전체. world.frameAt(t) 로 아무 시각이나 읽는다
clock재생 시계

phase 는 idle(첫 출발 전) · walk · wait(비켜 주려고 멈춤, 앉은 채 기다림) · stand-up · sit-down · act(자리에서 하는 중) · away(문 밖) 중 하나다.

이벤트로 받기 ​

도착·자리 비움 같은 사건은 people 을 앞 틱과 비교해서 얻는다.

tsx
const previous = useRef(behavior.people)
useEffect(() => {
  for (const [id, now] of Object.entries(behavior.people)) {
    const before = previous.current[id]
    if (now.phase === 'act' && before?.phase !== 'act') onArrived(id, now.spot, now.activity)
    if (now.phase === 'away' && before?.phase !== 'away') onLeft(id)
  }
  previous.current = behavior.people
}, [behavior.people])

머리 위 이름표 ​

tsx
<IsoScene
  scene={scene}
  registry={registry}
  behavior={behavior}
  renderOverlay={({ top }) =>
    [...behavior.ids].map((id) => {
      const at = top(id)                        // 이번 프레임 머리 위 (걷는 중에도 따라간다)
      return at && <text key={id} x={at[0]} y={at[1] - 4} textAnchor="middle" fontSize={8} pointerEvents="none">{id}</text>
    })
  }
/>

재생 제어 ​

행동과 타임라인이 같은 시계를 쓰게 하고, 앱이 재생·멈춤·배속·시각 이동을 한다.

tsx
const [clock] = useState(() => createTimelineClock())
const behavior = useBehaviorPlayback(scene, registry, { clock })

useEffect(() => {
  clock.play()
  return () => clock.pause()
}, [clock])

<button onClick={() => clock.pause()}>멈춤</button>
<button onClick={() => clock.setSpeed(4)}>4배속</button>
<button onClick={() => clock.seek(0)}>처음으로</button>
<IsoScene scene={scene} registry={registry} behavior={behavior} clock={clock} playTimelines />
  • 시계를 넘기면 재생·멈춤·배속은 시계를 따르고 behaviorSpeed·timelineSpeed 는 보지 않는다. playTimelines·playBehaviors 는 켜고 끄는 데만 쓴다. behavior 를 넘기면 playBehaviors 도 보지 않는다 (앱의 재생을 그대로 그린다).
  • 하루 시각은 clockMinutes(scene.clock, clock.getState().time) 으로 얻는다 (formatClock 으로 '09:12').
  • 앞으로 건너뛰기(seek)는 그 시각까지 계획을 세우므로 1시간 분량이면 수백 ms 걸린다.

주의 ​

  • 명령은 world 안에 있다. 씬을 고쳐 world 를 새로 만들면 useBehaviorPlayback 이 지난 명령을 같은 시각에 다시 주지만, 다른 씬(meta.id 가 다른)을 열면 기록을 지운다.
  • 되감은(seek) 뒤에 준 명령도 이미 그린 가장 늦은 시각에 들어간다. 지난 시각의 계획은 고치지 않는다.
  • 동작 줄이기(prefers-reduced-motion)면 재생이 0초에 멈추므로 명령해도 움직이는 모습은 보이지 않는다.
  • 층을 오가지 않는다. 계단·엘리베이터는 아직 없다.

MIT 라이선스로 배포합니다.