본문으로 건너뛰기

dotgimon 제작기 11. 대기 행동과 액션 아틀라스

·15 min read·11 / 32

dotgimon의 36px 몬스터는 메인 화면에서 걷다가 멈추는 정도만 반복했다. 입력하지 않을 때도 가끔 주변을 살피거나 기지개를 켜게 만들었다. 동작을 늘리면서도 버튼 입력과 다른 연출이 방해받지 않는 쪽으로 조건을 잡았다.

날짜는 작업 순서에 맞춰 배치했다. 대기 행동과 액션 아틀라스 변경은 실제로 9월 30일에 저장소에 반영했다. 첫 구현은 스프라이트 전체를 움직였고, 아틀라스를 연결한 뒤에는 동작별 프레임을 골랐다.

움직임보다 멈춰야 할 조건이 많았다

사용자가 아무것도 누르지 않을 때만 가끔 움직이고, 버튼을 누르면 바로 멈추는 행동이 필요했다. 전투나 진화 결과를 바꾸지 않는 연출이라 저장에서도 제외했다.

한가한 상태를 고르는 조건은 길어졌다. 자거나 불이 꺼져 있으면 안 되고, 아프거나 배고픔이 0인 때도 제외했다. 먹기·청소·진화·전투 같은 연출과 겹치지 않는지도 확인했다.

첫 구현은 engine.ts의 canIdle에 조건을 모았다. 파일을 나눈 뒤에는 반응·배변 연출 조건도 더했다. 아래 코드는 그 조건까지 포함한 형태다.

// src/game/engine/sprites.ts
canIdle(this: Game, now: number) {
  const s = this.s
  return s.screen === 'MAIN' && s.hatched && !s.dead && s.lightOn && !s.sleeping &&
    !s.sick && s.hunger > 0 && !s.resetAnim && !this.busy() && !s.message &&
    !s.emote && !this.reacting(now) && !this.evoOmenPause(now) && !this.straining(now) && now - this.inputAt >= 8000
}

메인 화면의 부화한 몬스터만 대상이었다. 마지막 조건은 버튼 입력 후 최소 8초가 지났는지를 검사했다. 새 연출이 추가될 때마다 여러 곳을 고치지 않도록 대기 행동의 자격을 한 함수에서 확인했다.

일곱 행동을 같은 확률로 뽑지는 않았다

두리번거리기, 쉬기, 관심 표현, 기지개, 몸 털기, 바닥 살피기, 작은 점프를 넣었다. 그중 기지개·몸 털기·작은 점프는 움직임이 큰 lively 행동으로 구분했다.

idle-behavior.ts의 테이블에는 선택 가중치와 기본 길이, 길이에 더할 난수 범위를 적었다. 첫 구현 당시 파일명은 idleBehavior.ts였다.

// src/game/idle-behavior.ts
const ACTIONS: { kind: IdleKind; weight: number; duration: number; variation: number; lively: boolean }[] = [
  { kind: 'look', weight: 3, duration: 3600, variation: 1600, lively: false },
  { kind: 'rest', weight: 4, duration: 6000, variation: 3000, lively: false },
  { kind: 'curious', weight: 3, duration: 3600, variation: 1600, lively: false },
  { kind: 'stretch', weight: 1, duration: 3400, variation: 800, lively: true },
  { kind: 'shake', weight: 1, duration: 2400, variation: 800, lively: true },
  { kind: 'sniff', weight: 2, duration: 4200, variation: 1000, lively: false },
  { kind: 'hop', weight: 1, duration: 2400, variation: 800, lively: true },
]

기본 가중치 합은 조용한 행동 12, 활발한 행동 3이었다. 실제 선택에서는 직전 행동을 빼므로 활발한 행동의 확률이 늘 20%인 것은 아니다. 활발한 행동 바로 뒤에는 활발한 행동 전체를 제외했다.

행동 길이는 duration에 0 이상 variation 미만의 값을 더했다. 두리번거리기와 관심 표현은 약 3.65.2초, 쉬기는 69초였다. 기지개는 3.44.2초, 몸 털기와 작은 점프는 2.43.2초, 바닥 살피기는 4.2~5.2초로 잡았다.

행동 사이에는 다시 걷게 했다

행동 하나를 마치면 곧바로 다음 동작을 시작하지 않았다. 3,500~7,499ms 뒤로 다음 행동을 예약하고 그 사이에는 평소처럼 걸었다.

update(now: number, eligible: boolean) {
  if (!eligible) { this.cancel(); return }
  if (this.action) {
    if (now < this.action.until) return
    this.action = null
    this.nextAt = now + 3500 + Math.floor(this.random() * 4000)
    return
  }
  if (this.nextAt === null) {
    this.nextAt = now + 3500 + Math.floor(this.random() * 4000)
    return
  }
  if (now < this.nextAt) return
  const wasLively = ACTIONS.some(a => a.kind === this.previous && a.lively)
  const choices = ACTIONS.filter(a => a.kind !== this.previous && !(wasLively && a.lively))
  let roll = this.random() * choices.reduce((sum, a) => sum + a.weight, 0)
  const choice = choices.find(a => { roll -= a.weight; return roll < 0 }) ?? choices[choices.length - 1]
  // ... duration 계산, this.action = { kind, start: now, until: now + duration }
}

예약 시각이 오면 직전 행동과 활발한 연속 행동을 후보에서 뺐다. 남은 가중치의 합으로 난수를 뽑았으므로 같은 동작이 연달아 나오지 않았다.

자격 조건이 깨지면 진행 중인 행동과 예약을 함께 지웠다. 중단한 동작을 나중에 이어 하지 않고 다시 예약했다. 입력 후 첫 동작까지는 8초 대기에 첫 산책 구간인 약 3.5~7.5초가 더해졌다.

연출을 뽑을 때 게임의 Math.random을 소비하면 진화나 전투에 영향을 줄 수 있었다. 대기 행동에는 이전 난수 값으로 다음 값을 계산하는 자체 선형 합동 생성기(LCG)를 넣었다.

private random() {
  this.seed = (Math.imul(this.seed, 1664525) + 1013904223) >>> 0
  return this.seed / 0x100000000
}

생성 시 전달받은 시각을 시드로 썼다. 시드와 시간을 고정하면 같은 행동 순서를 재현할 수 있었다. 테스트에서도 Math.random을 감시해 대기 행동이 그 함수를 호출하지 않는지 확인했다.

버튼 입력이 들어오면 바로 양보했다

게임 루프에서는 프레임 간격이 1초 미만이고 canIdle을 만족할 때만 대기 행동을 갱신했다.

// src/game/engine/update-care.ts
this.idle.update(now, dt < 1000 && this.canIdle(now))

백그라운드에서 돌아오거나 오프라인 시간을 정산해 dt가 커지면 행동을 취소했다. 120초를 건너뛴 테스트에서도 idleAction이 null인지 확인했다.

버튼을 처리하는 press 맨 앞에서 idle.cancel()을 호출하고 마지막 입력 시각을 바꿨다. 대기 행동 중 A를 누르면 즉시 취소되고, 이후 8초 동안 새 행동이 시작되지 않는지 테스트했다. 스프라이트도 다음 업데이트까지 기다리지 않고 다른 연출로 넘어가는지 살폈다.

수면, 소등, 질병, 배고픔 0, STATUS 화면, 먹기, 감정 표현, 리셋도 같은 방식으로 검사했다. 저장한 상태에는 idle과 idleAction이 들어가지 않도록 테스트를 넣었다. 앱을 다시 켰을 때 이전 동작을 복구할 필요는 없었다.

처음에는 그림 전체를 움직였다

첫 구현은 새 도트 없이 기존 스프라이트를 이동하거나 회전했다. idlePose에서 동작 시작 후 지난 시간에 따라 자세를 계산했다. 기지개는 코사인 곡선으로 올라가 잠깐 유지했다가 내려왔다.

// src/game/idleBehavior.ts (첫 구현의 idlePose)
const hold = (start: number, rise: number, stay: number, fall: number) => { /* 코사인 이징으로 올라가 유지했다 내려오는 0~1 값 */ }
if (action.kind === 'stretch') {
  const reach = hold(400, 650, 1100, 650)
  return { ...base, rot: 0 - facing * Math.round(6 * reach), dy: 0 - Math.round(2 * reach) }
}

몸 전체가 하나로 움직이니 팔이나 머리만 따로 움직일 수 없었다. 화면 끝에서 회전할 때 잘리지 않도록 위치도 제한했다. 좌우 끝 x=6, 42에서 두 방향을 각각 5.5초 동안 50ms 간격으로 검사했다.

첫 구현에서 두리번거리기는 좌우 방향을 바꾸고, 쉬기는 1.8초 간격으로 기존 두 프레임을 반복했다. 관심 표현은 두 번 작게 기울였고, 기지개는 몸을 2칸 들어 뒤로 기울였다. 몸 털기는 1초 동안 좌우로 1칸씩 움직였으며, 바닥 살피기는 앞으로 숙인 채 작은 움직임을 섞었다. 작은 점프는 4칸과 3칸 높이로 두 번 뛰게 했다.

기존 그림을 변형하지 않는 범위에서 움직이려다 보니 경계 처리와 자세 계산이 늘었다. 동작별로 그린 프레임을 연결하면서 이 계산을 없앴다.

동작 프레임을 고르니 회전 계산이 없어졌다

액션 아틀라스는 여러 동작의 그림을 한 이미지에 모은 에셋이다. 동작 20종에 각각 네 프레임을 배정하고, 종마다 총 80프레임을 가로로 이어 붙였다.

// src/game/action-animation.ts
export const ACTIONS = ['idle', 'walk', 'attack', 'hit', 'defeat', 'happy', 'sad', 'eat', 'sleep', 'heal', 'strain', 'look', 'stretch', 'sniff', 'hop', 'tumble', 'rest', 'curious', 'shake', 'sick'] as const
 
export function actionAtlasFrame(action: MonsterAction, elapsed: number): number {
  if (action === 'dodge') return 0
  return ACTIONS.indexOf(action) * 4 + actionFrame(action, elapsed)
}

ACTIONS[i]의 프레임은 i * 4부터 네 장이다. blip.png는 36px 프레임 80장을 붙인 2880×36 이미지였다. 두리번거리기는 650ms 간격으로 반복하고, 기지개는 500ms 간격으로 한 번 재생한 뒤 마지막 프레임을 유지했다.

export function actionFrame(action: MonsterAction, elapsed: number): number {
  const timing = ACTION_TIMING[action]
  const frame = Math.floor(Math.max(0, elapsed) / timing.ms)
  return timing.loop ? frame % 4 : Math.min(3, frame)
}

반복 동작은 네 프레임을 순환하고, 반복하지 않는 동작은 인덱스 3인 마지막 프레임에서 멈췄다. 엔진에서는 동작 이름과 시작 후 지난 시간을 프레임 선택 함수에 넘겼다.

아틀라스 제작 스크립트 scripts/build-action-sprites.mjs는 가로 8칸·세로 10칸으로 그린 입력 이미지를 읽었다. 셀 테두리를 지우고 자른 뒤 균등하게 샘플링해 36px 스트립으로 묶었다. 중립 자세를 찌그러뜨리거나 회전해 새 자세를 만드는 방식은 쓰지 않았다.

엔진에서는 먹기·청소·수면·감정 표현처럼 우선하는 상태 뒤에 대기 행동을 배치했다. 똥이 두 개 이상이면 어떤 대기 행동이 선택됐든 바닥 살피기 프레임을 보여줬다.

// src/game/engine/sprites.ts (actionSprite 발췌)
else if (this.idle.action && this.canIdle(now)) choose(s.poop >= 2 ? 'sniff' : this.idle.action.kind, this.idle.action.start)
else { action = this.canWalk(now) ? 'walk' : 'idle'; elapsed = action === 'walk' ? this.walkTime : now }
// ...
return { ...layout, /* ... */ action, frame: actionAtlasFrame(action, elapsed), rot: 0, hf: layout.wf }

대기 행동 중에는 걷기를 멈추고 회전값 rot는 0으로 고정했다. Lcd에서도 명시적인 액션 프레임이 있으면 기본 700ms 루프로 덮어쓰지 않았다.

docs/idle-behaviors.md에는 전용 정면·앉기·눈 깜빡임 도트를 추가하지 않았다는 첫 구현의 설명이 남아 있었다. 아틀라스를 연결한 코드는 이미 동작별 그림을 선택하고 있었다. 두 구현을 같은 상태로 보지 않도록 구분했다.

테스트에서는 30분을 100ms 간격으로 진행시켰다. 일곱 행동이 모두 나오는지, 조용한 행동이 활발한 행동보다 두 배 넘게 많은지, 같은 행동이나 활발한 행동이 연속되지 않는지 확인했다. 행동 사이 간격은 최소 3.5초이고 길이도 여러 값으로 달라지는지 검사했다.

시드와 시간을 인자로 받으니 실제 30분을 기다릴 필요가 없었다. 입력을 방해하지 않는 조건과 행동 선택은 코드로 확인했지만, 동작이 자연스러워 보이는지 기기에서 육안으로 확인한 결과는 없었다.