사람 움직이기
사람을 움직이는 방법은 세 가지다. 한 씬에서 섞어 써도 된다.
| 방법 | 어떻게 | 언제 |
|---|---|---|
| 루틴 | 오브젝트에 behavior: { routine } 를 준다. 엔진이 자리를 골라 걸어가 앉고 일하고 쉰다 | 살아 있는 사무실·공장 배경 |
| 명령 | 앱이 command(id, …) 로 "저기로 가라", "3번 의자에 앉아라", "나가라" 를 준다 | 출입 기록·호출·회의 같은 실제 이벤트를 보여 줄 때 |
| 씬 데이터 | 오브젝트 position·pose 를 바꾼다. 바뀐 사이를 걷기·앉기로 잇는다 (길 찾기 없이 곧게) | 편집기, 드문 순간 이동 |
루틴과 명령은 같은 엔진(createBehaviorWorld)이 계획한다. 그래서 명령받은 사람도 가구·벽을 돌아가고, 루틴대로 걷는 사람과 서로 비켜 가며, 앉을 때 책상·등받이를 뚫지 않는다.
루틴
{ "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이 있을 때 켜진다.
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> 에 넘긴다. 같은 재생을 앱과 화면이 함께 보므로, 앱이 준 명령이 바로 화면에 나온다.
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을 빼면: 뒤에 명령이 있으면 도착하자마자 다음으로 넘어가고, 마지막 명령이면 다음 명령이 올 때까지 이어 한다.
// 순찰: 세 곳을 돌고 정수기에서 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' }) // 나갔던 문으로 들어와 자리로결과
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
서버 상태·센서 값처럼 계속 바뀌는 데이터를 사람 움직임으로 옮길 때는, 사람마다 지금 해야 할 일을 선언한다. 값이 바뀐 사람에게만 명령이 가고, 같은 값이면 다시 보내지 않는다 (렌더마다 새 객체를 만들어도 된다).
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 없이)
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 을 앞 틱과 비교해서 얻는다.
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])머리 위 이름표
<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>
})
}
/>재생 제어
행동과 타임라인이 같은 시계를 쓰게 하고, 앱이 재생·멈춤·배속·시각 이동을 한다.
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초에 멈추므로 명령해도 움직이는 모습은 보이지 않는다. - 층을 오가지 않는다. 계단·엘리베이터는 아직 없다.