clawmate 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,182 +1,777 @@
1
1
  /**
2
- * 핵심 이동/애니메이션 엔진
3
- * requestAnimationFrame 기반 이동 루프 화면 4면 가장자리 이동
2
+ * 핵심 이동/물리 엔진 (리뉴얼)
3
+ * requestAnimationFrame 기반 — 스텝 이동 + 점프 + 레펠 + 중력 낙하
4
+ *
5
+ * 이동 모드:
6
+ * crawling — 표면 위에서 한발자국씩 기어가는 이동
7
+ * jumping — 포물선 궤도 점프
8
+ * falling — 중력에 의한 자유 낙하
9
+ * rappelling — 실(thread)을 타고 진자운동하며 하강
4
10
  */
5
11
  const PetEngine = (() => {
6
- const BASE_SPEED = 1.5;
7
- const CLIMB_SPEED = 1.0;
8
- const CHAR_SIZE = 64;
9
- const ANIM_INTERVAL = 250; // ms per frame
12
+ // --- 물리 상수 ---
13
+ const GRAVITY = 0.3; // 중력 가속도 (px/frame^2)
14
+ const STEP_SIZE = 4; // 한 걸음 크기 (px)
15
+ const STEP_PAUSE = 80; // 걸음 사이 멈춤 시간 (ms)
16
+ const JUMP_VX = 3; // 점프 수평 초기 속도
17
+ const JUMP_VY = -7; // 점프 수직 초기 속도 (위로)
18
+ const BOUNCE_FACTOR = 0.3; // 착지 바운스 계수
19
+ const CHAR_SIZE = 64; // 캐릭터 크기 (px)
20
+ const ANIM_INTERVAL = 200; // 애니메이션 프레임 전환 간격 (ms)
21
+ const THREAD_SPEED = 0.8; // 레펠 하강 속도 (px/frame)
10
22
 
23
+ // --- 위치 및 속도 ---
11
24
  let x = 0, y = 0;
12
- let edge = 'bottom'; // bottom, left, right, top
13
- let direction = 1; // 1=right/down, -1=left/up
14
- let flipX = false;
15
- let screenW = window.innerWidth;
16
- let screenH = window.innerHeight;
25
+ let vx = 0, vy = 0; // 현재 속도 벡터
26
+
27
+ // --- 표면/방향 ---
28
+ let edge = 'bottom'; // 현재 부착된 가장자리 (bottom, left, right, top, surface)
29
+ let direction = 1; // 이동 방향: 1=오른쪽/아래, -1=왼쪽/위
30
+ let flipX = false; // 캐릭터 좌우 반전 여부
31
+ let screenW, screenH;
32
+
33
+ // --- 엔진 상태 ---
17
34
  let running = false;
35
+ let petContainer = null;
36
+ let speedMultiplier = 1.0;
18
37
  let animFrame = 0;
19
38
  let lastAnimTime = 0;
20
- let speedMultiplier = 1.0;
21
- let petContainer = null;
22
39
 
40
+ // --- 이동 모드 ---
41
+ let movementMode = 'crawling'; // crawling | jumping | falling | rappelling
42
+ let onSurface = true; // 표면 위에 있는지 여부
43
+
44
+ // --- 스텝 시스템 (뚝뚝 끊어 걷기) ---
45
+ let stepPhase = 'move'; // 'move' 또는 'pause'
46
+ let lastStepTime = 0;
47
+
48
+ // --- 레펠(thread) 시스템 ---
49
+ // 부착점에서 실을 내려 진자운동하며 하강
50
+ let thread = null; // { attachX, attachY, length, angle, swingVel } | null
51
+
52
+ // --- 윈도우 표면 목록 ---
53
+ // 외부 윈도우의 타이틀바 등을 추가 표면으로 등록
54
+ let windowSurfaces = []; // [{ id, x, y, width, height }]
55
+
56
+ // --- 착지 시 현재 올라가 있는 표면 참조 ---
57
+ let currentSurface = null;
58
+
59
+ /**
60
+ * 초기화: 컨테이너 설정 및 화면 하단 중앙 배치
61
+ */
23
62
  function init(container) {
24
63
  petContainer = container;
25
- // 화면 하단 중앙에서 시작
26
64
  screenW = window.innerWidth;
27
65
  screenH = window.innerHeight;
66
+
67
+ // 화면 하단 중앙에서 시작
28
68
  x = (screenW - CHAR_SIZE) / 2;
29
69
  y = screenH - CHAR_SIZE;
30
70
  edge = 'bottom';
31
71
  direction = 1;
32
- updatePosition();
72
+ movementMode = 'crawling';
73
+ onSurface = true;
74
+ currentSurface = null;
75
+ updateVisual();
33
76
 
77
+ // 화면 크기 변경 대응
34
78
  window.addEventListener('resize', () => {
35
79
  screenW = window.innerWidth;
36
80
  screenH = window.innerHeight;
37
81
  clampPosition();
38
- updatePosition();
82
+ updateVisual();
39
83
  });
40
84
  }
41
85
 
86
+ /**
87
+ * 속도 배율 설정 (성격/진화 단계에 따른 속도 조절)
88
+ */
42
89
  function setSpeedMultiplier(mult) {
43
90
  speedMultiplier = mult;
44
91
  }
45
92
 
93
+ /**
94
+ * 화면 경계 내로 위치 제한
95
+ */
46
96
  function clampPosition() {
47
97
  x = Math.max(0, Math.min(x, screenW - CHAR_SIZE));
48
98
  y = Math.max(0, Math.min(y, screenH - CHAR_SIZE));
49
99
  }
50
100
 
51
- function updatePosition() {
101
+ // ===================================
102
+ // 시각적 업데이트
103
+ // ===================================
104
+
105
+ /**
106
+ * 컨테이너 위치 및 회전/반전 업데이트
107
+ * 가장자리별로 캐릭터가 올바른 방향을 향하도록 transform 적용
108
+ *
109
+ * EDGE_OFFSET: 스프라이트 테두리의 빈 픽셀(4px)을 보정하여
110
+ * 다리가 실제로 벽면/바닥/천장에 밀착하도록 렌더링 위치 조정
111
+ */
112
+ const EDGE_OFFSET = 4;
113
+
114
+ function updateVisual() {
52
115
  if (!petContainer) return;
53
- petContainer.style.left = x + 'px';
54
- petContainer.style.top = y + 'px';
55
116
 
56
- // 가장자리별 회전/반전 — 다리(캐릭터 하단)가 항상 해당 가장자리를 향하도록
117
+ let renderX = x;
118
+ let renderY = y;
119
+
120
+ // 표면에 붙어있을 때만 오프셋 적용 (공중 상태에서는 불필요)
121
+ if (onSurface && movementMode === 'crawling') {
122
+ switch (edge) {
123
+ case 'bottom':
124
+ case 'surface':
125
+ renderY += EDGE_OFFSET; // 바닥: 다리를 아래로 밀착
126
+ break;
127
+ case 'top':
128
+ renderY -= EDGE_OFFSET; // 천장: 다리를 위로 밀착
129
+ break;
130
+ case 'left':
131
+ renderX -= EDGE_OFFSET; // 왼쪽 벽: 다리를 왼쪽으로 밀착
132
+ break;
133
+ case 'right':
134
+ renderX += EDGE_OFFSET; // 오른쪽 벽: 다리를 오른쪽으로 밀착
135
+ break;
136
+ }
137
+ }
138
+
139
+ petContainer.style.left = renderX + 'px';
140
+ petContainer.style.top = renderY + 'px';
141
+
57
142
  let transform = '';
58
- if (edge === 'left') {
59
- // 왼쪽 벽: 다리가 왼쪽(화면 가장자리)을 향하도록 반시계 회전
60
- transform += 'rotate(-90deg) ';
61
- if (flipX) transform += 'scaleX(-1) ';
143
+
144
+ if (movementMode === 'rappelling' || movementMode === 'jumping' || movementMode === 'falling') {
145
+ // 공중 상태: 바닥 기준 기본 자세 (회전 없음)
146
+ if (flipX) transform = 'scaleX(-1)';
147
+ } else if (edge === 'left') {
148
+ // 왼쪽 벽: 다리가 왼쪽 가장자리를 향하도록 반시계 회전
149
+ transform = 'rotate(-90deg)';
150
+ if (flipX) transform += ' scaleX(-1)';
62
151
  } else if (edge === 'right') {
63
- // 오른쪽 벽: 다리가 오른쪽(화면 가장자리)을 향하도록 시계 회전
64
- transform += 'rotate(90deg) ';
65
- if (flipX) transform += 'scaleX(-1) ';
152
+ // 오른쪽 벽: 다리가 오른쪽 가장자리를 향하도록 시계 회전
153
+ transform = 'rotate(90deg)';
154
+ if (flipX) transform += ' scaleX(-1)';
66
155
  } else if (edge === 'top') {
67
- // 천장: 다리가 위(천장)를 향하도록 상하 반전
68
- transform += 'scaleY(-1) ';
69
- if (flipX) transform += 'scaleX(-1) ';
156
+ // 천장: 다리가 위를 향하도록 상하 반전
157
+ transform = 'scaleY(-1)';
158
+ if (flipX) transform += ' scaleX(-1)';
70
159
  } else {
71
- // 바닥: 기본 자세 (다리가 아래를 향함)
72
- if (flipX) transform += 'scaleX(-1) ';
160
+ // 바닥/표면: 기본 자세
161
+ if (flipX) transform = 'scaleX(-1)';
73
162
  }
74
- petContainer.style.transform = transform.trim() || 'none';
163
+
164
+ petContainer.style.transform = transform || 'none';
165
+
166
+ // 레펠 스레드 시각화 갱신
167
+ updateThreadVisual();
75
168
  }
76
169
 
77
- function moveForState(state) {
78
- const speed = BASE_SPEED * speedMultiplier;
79
- const climbSpeed = CLIMB_SPEED * speedMultiplier;
80
-
81
- switch (state) {
82
- case 'walking':
83
- if (edge === 'bottom' || edge === 'top') {
84
- x += speed * direction;
85
- flipX = direction < 0;
86
- // 벽에 닿으면 방향 전환
87
- if (x <= 0) { x = 0; direction = 1; }
88
- if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
89
- }
90
- break;
170
+ /**
171
+ * SVG 라인으로 레펠 실(thread) 시각화
172
+ * thread가 없으면 숨기고, 있으면 부착점 → 캐릭터 상단 연결
173
+ */
174
+ function updateThreadVisual() {
175
+ const line = document.getElementById('thread-line');
176
+ if (!line) return;
177
+
178
+ if (!thread) {
179
+ // 실이 없으면 숨김
180
+ line.setAttribute('x1', '0');
181
+ line.setAttribute('y1', '0');
182
+ line.setAttribute('x2', '0');
183
+ line.setAttribute('y2', '0');
184
+ line.style.display = 'none';
185
+ return;
186
+ }
187
+
188
+ // 부착점에서 캐릭터 상단 중앙까지 실 표시
189
+ line.style.display = 'block';
190
+ line.setAttribute('x1', thread.attachX);
191
+ line.setAttribute('y1', thread.attachY);
192
+ line.setAttribute('x2', x + CHAR_SIZE / 2);
193
+ line.setAttribute('y2', y);
194
+ line.setAttribute('stroke', '#888');
195
+ line.setAttribute('stroke-width', '1');
196
+ }
197
+
198
+ // ===================================
199
+ // 스텝 기반 이동 (뚝뚝 끊어 걷기)
200
+ // ===================================
201
+
202
+ /**
203
+ * 스텝 이동: STEP_SIZE만큼 이동 → STEP_PAUSE만큼 멈춤 반복
204
+ * 한 발자국씩 끊어서 기어가는 느낌을 줌
205
+ *
206
+ * @param {number} stepScale - 스텝 크기 배율 (0.6 = 짐 들고 느리게, 1.0 = 기본)
207
+ * @param {number} now - 현재 시각 (Date.now())
208
+ */
209
+ function stepMove(stepScale, now) {
210
+ // 멈춤 단계: 아직 대기 시간이 남았으면 아무것도 안 함
211
+ if (stepPhase === 'pause') {
212
+ if (now - lastStepTime >= STEP_PAUSE) {
213
+ stepPhase = 'move';
214
+ }
215
+ return;
216
+ }
217
+
218
+ // 이동 단계: 한 걸음 전진
219
+ const stepDist = STEP_SIZE * stepScale * speedMultiplier;
220
+
221
+ if (edge === 'bottom' || edge === 'top' || edge === 'surface') {
222
+ // 수평 이동 (바닥, 천장, 윈도우 표면)
223
+ x += stepDist * direction;
224
+ flipX = direction < 0;
225
+ } else if (edge === 'left') {
226
+ // 왼쪽 벽: y축 이동 (direction=1이면 아래로, -1이면 위로)
227
+ y += stepDist * direction;
228
+ } else if (edge === 'right') {
229
+ // 오른쪽 벽: y축 이동
230
+ y += stepDist * direction;
231
+ }
232
+
233
+ // 한 걸음 완료, 멈춤 단계로 전환
234
+ stepPhase = 'pause';
235
+ lastStepTime = now;
236
+ }
237
+
238
+ // ===================================
239
+ // 윈도우 표면 탐지
240
+ // ===================================
91
241
 
92
- case 'ceiling_walk':
93
- if (edge === 'top') {
94
- x += speed * direction;
95
- flipX = direction < 0;
96
- if (x <= 0) { x = 0; direction = 1; }
97
- if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
242
+ /**
243
+ * 주어진 위치 아래에 있는 윈도우 표면을 찾음
244
+ * 캐릭터가 수평 범위 안에 있고, 표면 상단 근처에 있을 때 착지 가능
245
+ *
246
+ * @param {number} px - 캐릭터 x 좌표
247
+ * @param {number} py - 캐릭터 하단 y 좌표 (y + CHAR_SIZE)
248
+ * @returns {object|null} 착지 가능한 표면 또는 null
249
+ */
250
+ function findSurfaceBelow(px, py) {
251
+ let closest = null;
252
+ let closestDist = Infinity;
253
+
254
+ for (const s of windowSurfaces) {
255
+ // 수평 범위 확인: 캐릭터가 표면 위에 겹치는지
256
+ if (px + CHAR_SIZE > s.x && px < s.x + s.width) {
257
+ // 표면 상단에 근접했는지 (위에서 떨어지는 중)
258
+ if (py >= s.y && py <= s.y + 10) {
259
+ const dist = Math.abs(py - s.y);
260
+ if (dist < closestDist) {
261
+ closestDist = dist;
262
+ closest = s;
263
+ }
98
264
  }
99
- break;
265
+ }
266
+ }
267
+ return closest;
268
+ }
100
269
 
101
- case 'climbing_up':
102
- if (edge === 'bottom') {
103
- // 바닥에서 벽으로
104
- if (direction > 0) {
105
- x = screenW - CHAR_SIZE;
106
- edge = 'right';
270
+ /**
271
+ * 외부에서 윈도우 표면 목록 등록
272
+ * (예: 다른 창의 타이틀바를 걸어다닐 수 있는 표면으로 설정)
273
+ *
274
+ * @param {Array} surfaces - [{ id, x, y, width, height }]
275
+ */
276
+ function setSurfaces(surfaces) {
277
+ windowSurfaces = surfaces || [];
278
+ }
279
+
280
+ // ===================================
281
+ // 물리 상태별 이동 처리
282
+ // ===================================
283
+
284
+ /**
285
+ * 메인 이동 로직: movementMode에 따라 물리 연산 수행
286
+ *
287
+ * @param {string} state - StateMachine의 현재 상태 (walking, idle 등)
288
+ */
289
+ function moveForState(state) {
290
+ const now = Date.now();
291
+
292
+ switch (movementMode) {
293
+
294
+ // --- 포물선 점프 ---
295
+ case 'jumping':
296
+ vy += GRAVITY; // 중력 적용
297
+ x += vx;
298
+ y += vy;
299
+ flipX = vx < 0;
300
+
301
+ // 바닥 착지 감지
302
+ if (y >= screenH - CHAR_SIZE) {
303
+ y = screenH - CHAR_SIZE;
304
+ // 바운스 효과: 약간 튕김
305
+ if (Math.abs(vy) > 2) {
306
+ vy = -vy * BOUNCE_FACTOR;
107
307
  } else {
108
- x = 0;
109
- edge = 'left';
308
+ edge = 'bottom';
309
+ movementMode = 'crawling';
310
+ onSurface = true;
311
+ currentSurface = null;
312
+ vx = 0;
313
+ vy = 0;
110
314
  }
111
315
  }
112
- y -= climbSpeed;
113
- if (y <= 0) {
316
+
317
+ // 윈도우 표면 착지 감지 (아래로 떨어지는 중일 때만)
318
+ if (vy > 0 && movementMode === 'jumping') {
319
+ const landSurface = findSurfaceBelow(x, y + CHAR_SIZE);
320
+ if (landSurface) {
321
+ y = landSurface.y - CHAR_SIZE;
322
+ edge = 'surface';
323
+ movementMode = 'crawling';
324
+ onSurface = true;
325
+ currentSurface = landSurface;
326
+ vx = 0;
327
+ vy = 0;
328
+ }
329
+ }
330
+
331
+ // 벽/천장 충돌 → 해당 가장자리에 붙음
332
+ if (x <= 0 && movementMode === 'jumping') {
333
+ x = 0;
334
+ edge = 'left';
335
+ movementMode = 'crawling';
336
+ onSurface = true;
337
+ currentSurface = null;
338
+ vx = 0;
339
+ vy = 0;
340
+ direction = 1; // 아래쪽 방향
341
+ }
342
+ if (x >= screenW - CHAR_SIZE && movementMode === 'jumping') {
343
+ x = screenW - CHAR_SIZE;
344
+ edge = 'right';
345
+ movementMode = 'crawling';
346
+ onSurface = true;
347
+ currentSurface = null;
348
+ vx = 0;
349
+ vy = 0;
350
+ direction = 1;
351
+ }
352
+ if (y <= 0 && movementMode === 'jumping') {
114
353
  y = 0;
115
354
  edge = 'top';
355
+ movementMode = 'crawling';
356
+ onSurface = true;
357
+ currentSurface = null;
358
+ vx = 0;
359
+ vy = 0;
360
+ direction = 1; // 오른쪽 방향
116
361
  }
117
362
  break;
118
363
 
119
- case 'climbing_down':
120
- y += climbSpeed;
364
+ // --- 자유 낙하 (중력) ---
365
+ case 'falling':
366
+ vy += GRAVITY;
367
+ y += vy;
368
+
369
+ // 윈도우 표면 착지 감지
370
+ if (vy > 0) {
371
+ const fallSurface = findSurfaceBelow(x, y + CHAR_SIZE);
372
+ if (fallSurface) {
373
+ y = fallSurface.y - CHAR_SIZE;
374
+ edge = 'surface';
375
+ movementMode = 'crawling';
376
+ onSurface = true;
377
+ currentSurface = fallSurface;
378
+ vy = 0;
379
+ }
380
+ }
381
+
382
+ // 바닥 착지
121
383
  if (y >= screenH - CHAR_SIZE) {
122
384
  y = screenH - CHAR_SIZE;
123
385
  edge = 'bottom';
386
+ movementMode = 'crawling';
387
+ onSurface = true;
388
+ currentSurface = null;
389
+ vy = 0;
124
390
  }
125
391
  break;
126
392
 
127
- case 'scared':
128
- // 빠르게 도망
129
- x += speed * 2.5 * direction;
130
- flipX = direction < 0;
131
- if (x <= 0) { x = 0; direction = 1; }
132
- if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
133
- break;
393
+ // --- 레펠: 실을 타고 진자운동하며 하강 ---
394
+ case 'rappelling':
395
+ if (thread) {
396
+ // 길이 증가 → 하강
397
+ thread.length += THREAD_SPEED * speedMultiplier;
134
398
 
135
- case 'carrying':
136
- x += speed * 0.7 * direction;
137
- flipX = direction < 0;
138
- if (x <= 0) { x = 0; direction = 1; }
139
- if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
140
- break;
399
+ // 진자 흔들림 물리
400
+ thread.swingVel += Math.sin(thread.angle) * 0.01;
401
+ thread.swingVel *= 0.98; // 감쇠
402
+
403
+ thread.angle += thread.swingVel;
404
+
405
+ // 부착점 기준 진자 위치 계산
406
+ x = thread.attachX + Math.sin(thread.angle) * thread.length - CHAR_SIZE / 2;
407
+ y = thread.attachY + Math.cos(thread.angle) * thread.length;
141
408
 
142
- case 'excited':
143
- // 작은 점프 효과
144
- const elapsed = StateMachine.getElapsed();
145
- const jumpOffset = Math.sin(elapsed / 150) * 8;
146
- y = (screenH - CHAR_SIZE) + jumpOffset;
409
+ // 좌우 화면 경계 반사
410
+ if (x <= 0) {
411
+ x = 0;
412
+ thread.swingVel = Math.abs(thread.swingVel) * 0.5;
413
+ }
414
+ if (x >= screenW - CHAR_SIZE) {
415
+ x = screenW - CHAR_SIZE;
416
+ thread.swingVel = -Math.abs(thread.swingVel) * 0.5;
417
+ }
418
+
419
+ // 윈도우 표면 착지 감지
420
+ const rappelSurface = findSurfaceBelow(x, y + CHAR_SIZE);
421
+ if (rappelSurface) {
422
+ y = rappelSurface.y - CHAR_SIZE;
423
+ thread = null;
424
+ edge = 'surface';
425
+ movementMode = 'crawling';
426
+ onSurface = true;
427
+ currentSurface = rappelSurface;
428
+ }
429
+
430
+ // 바닥 도달
431
+ if (y >= screenH - CHAR_SIZE) {
432
+ y = screenH - CHAR_SIZE;
433
+ thread = null;
434
+ edge = 'bottom';
435
+ movementMode = 'crawling';
436
+ onSurface = true;
437
+ currentSurface = null;
438
+ }
439
+ }
147
440
  break;
148
441
 
149
- case 'idle':
150
- case 'sleeping':
151
- case 'interacting':
152
- case 'playing':
153
- // 정지 또는 미세한 흔들림
442
+ // --- 표면 위 기어가기 (스텝 기반) ---
443
+ case 'crawling':
444
+ default:
445
+ switch (state) {
446
+ case 'walking':
447
+ case 'ceiling_walk':
448
+ stepMove(1.0, now);
449
+
450
+ // 수평 이동 시 경계 처리
451
+ if (edge === 'bottom' || edge === 'top') {
452
+ if (x <= 0) { x = 0; direction = 1; }
453
+ if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
454
+ }
455
+
456
+ // 윈도우 표면 위 이동 시 가장자리에서 떨어짐
457
+ if (edge === 'surface' && currentSurface) {
458
+ if (x <= currentSurface.x - CHAR_SIZE / 2 ||
459
+ x >= currentSurface.x + currentSurface.width - CHAR_SIZE / 2) {
460
+ // 표면 가장자리에서 떨어짐 → 낙하 모드
461
+ movementMode = 'falling';
462
+ onSurface = false;
463
+ currentSurface = null;
464
+ vy = 0;
465
+ StateMachine.forceState('falling');
466
+ }
467
+ }
468
+ break;
469
+
470
+ case 'climbing_up':
471
+ if (edge === 'bottom' || edge === 'surface') {
472
+ // 바닥/표면에서 벽으로 전환
473
+ if (direction > 0) {
474
+ x = screenW - CHAR_SIZE;
475
+ edge = 'right';
476
+ } else {
477
+ x = 0;
478
+ edge = 'left';
479
+ }
480
+ currentSurface = null;
481
+ direction = -1; // 벽에서 위쪽 방향
482
+ }
483
+
484
+ // 벽에서 위로 기어오름: y 감소
485
+ if (edge === 'left' || edge === 'right') {
486
+ stepMove(0.7, now);
487
+ // stepMove가 direction(-1)을 적용하므로 y가 감소함
488
+ }
489
+
490
+ // 천장 도달
491
+ if (y <= 0) {
492
+ y = 0;
493
+ edge = 'top';
494
+ direction = 1; // 천장에서 오른쪽으로 이동
495
+ }
496
+ break;
497
+
498
+ case 'climbing_down':
499
+ // 벽에서 아래로 기어내려감: y 증가
500
+ if (edge === 'left' || edge === 'right') {
501
+ // direction을 1(아래)로 설정해서 stepMove
502
+ const prevDir = direction;
503
+ direction = 1;
504
+ stepMove(0.7, now);
505
+ direction = prevDir;
506
+ } else if (edge === 'top') {
507
+ // 천장에서 벽으로 내려가기 시작
508
+ if (x < screenW / 2) {
509
+ x = 0;
510
+ edge = 'left';
511
+ } else {
512
+ x = screenW - CHAR_SIZE;
513
+ edge = 'right';
514
+ }
515
+ direction = 1; // 아래 방향
516
+ }
517
+
518
+ // 바닥 도달
519
+ if (y >= screenH - CHAR_SIZE) {
520
+ y = screenH - CHAR_SIZE;
521
+ edge = 'bottom';
522
+ direction = Math.random() < 0.5 ? 1 : -1; // 랜덤 방향
523
+ }
524
+ break;
525
+
526
+ case 'scared':
527
+ // 도망: 스텝 스킵하고 빠르게 연속 이동
528
+ if (edge === 'bottom' || edge === 'top' || edge === 'surface') {
529
+ x += STEP_SIZE * 2.5 * direction * speedMultiplier;
530
+ flipX = direction < 0;
531
+ if (x <= 0) { x = 0; direction = 1; }
532
+ if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
533
+ }
534
+ break;
535
+
536
+ case 'carrying':
537
+ // 짐 들고 느리게 이동
538
+ stepMove(0.6, now);
539
+ if (edge === 'bottom' || edge === 'top' || edge === 'surface') {
540
+ if (x <= 0) { x = 0; direction = 1; }
541
+ if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
542
+ }
543
+ break;
544
+
545
+ case 'excited':
546
+ // 작은 점프 효과 (제자리에서 통통 뜀)
547
+ if (typeof StateMachine !== 'undefined') {
548
+ const elapsed = StateMachine.getElapsed();
549
+ const jumpOffset = Math.sin(elapsed / 150) * 8;
550
+ if (edge === 'bottom') {
551
+ y = (screenH - CHAR_SIZE) + jumpOffset;
552
+ } else if (edge === 'surface' && currentSurface) {
553
+ y = (currentSurface.y - CHAR_SIZE) + jumpOffset;
554
+ }
555
+ }
556
+ break;
557
+
558
+ // 점프 중 상태 (StateMachine에서 전이된 물리 상태)
559
+ case 'jumping':
560
+ // movementMode가 jumping이 아니면 시작
561
+ if (movementMode === 'crawling') {
562
+ _initiateRandomJump();
563
+ }
564
+ break;
565
+
566
+ // 레펠 중 상태
567
+ case 'rappelling':
568
+ if (movementMode === 'crawling') {
569
+ startRappel();
570
+ }
571
+ break;
572
+
573
+ // 낙하 중 상태
574
+ case 'falling':
575
+ if (movementMode === 'crawling') {
576
+ movementMode = 'falling';
577
+ onSurface = false;
578
+ vy = 0;
579
+ }
580
+ break;
581
+
582
+ case 'idle':
583
+ case 'sleeping':
584
+ case 'interacting':
585
+ case 'playing':
586
+ // 정지 또는 미세한 흔들림 (이동 없음)
587
+ break;
588
+ }
154
589
  break;
155
590
  }
156
591
 
157
592
  clampPosition();
158
- updatePosition();
593
+ updateVisual();
594
+ }
595
+
596
+ /**
597
+ * StateMachine이 jumping 상태로 전이했을 때 랜덤 목표로 점프
598
+ * 현재 위치 기준 화면 중앙 방향 또는 랜덤 위치로 도약
599
+ */
600
+ function _initiateRandomJump() {
601
+ // 화면 중앙 근처의 랜덤 목표 지점
602
+ const targetX = screenW * 0.2 + Math.random() * screenW * 0.6;
603
+ const targetY = screenH * 0.3 + Math.random() * screenH * 0.4;
604
+ jumpTo(targetX, targetY);
159
605
  }
160
606
 
607
+ // ===================================
608
+ // 점프 명령
609
+ // ===================================
610
+
611
+ /**
612
+ * 목표 지점을 향해 포물선 점프 시작
613
+ * 초기 속도(vx, vy)를 계산하여 포물선 궤도 생성
614
+ *
615
+ * @param {number} targetX - 목표 x 좌표
616
+ * @param {number} targetY - 목표 y 좌표
617
+ */
618
+ function jumpTo(targetX, targetY) {
619
+ if (movementMode !== 'crawling') return;
620
+
621
+ const dx = targetX - x;
622
+ const dy = targetY - y;
623
+ const dist = Math.hypot(dx, dy);
624
+
625
+ // 비행 시간 추정 (거리 기반)
626
+ const time = Math.max(20, dist / (JUMP_VX * 2 + 2));
627
+
628
+ // 포물선 초기 속도 계산
629
+ vx = dx / time;
630
+ vy = (dy / time) - (GRAVITY * time) / 2;
631
+
632
+ // vx, vy 범위 제한 (너무 빠르지 않게)
633
+ const maxV = 8;
634
+ vx = Math.max(-maxV, Math.min(maxV, vx));
635
+ vy = Math.max(-12, Math.min(maxV, vy));
636
+
637
+ movementMode = 'jumping';
638
+ onSurface = false;
639
+ currentSurface = null;
640
+
641
+ if (typeof StateMachine !== 'undefined') {
642
+ StateMachine.forceState('jumping');
643
+ }
644
+ }
645
+
646
+ // ===================================
647
+ // 레펠(Thread) 시스템
648
+ // ===================================
649
+
650
+ /**
651
+ * 레펠 시작: 천장이나 벽에서 실을 내려 하강
652
+ * 부착점을 현재 위치에 설정하고 진자운동 시작
653
+ */
654
+ function startRappel() {
655
+ // 천장, 왼쪽 벽, 오른쪽 벽에서만 레펠 가능
656
+ if (edge !== 'top' && edge !== 'left' && edge !== 'right') return;
657
+
658
+ let attachX, attachY;
659
+
660
+ if (edge === 'top') {
661
+ // 천장에서 레펠: 현재 위치 바로 위에 부착
662
+ attachX = x + CHAR_SIZE / 2;
663
+ attachY = 0;
664
+ } else if (edge === 'left') {
665
+ // 왼쪽 벽에서 레펠: 벽의 현재 y 위치에 부착
666
+ attachX = 0;
667
+ attachY = y;
668
+ } else {
669
+ // 오른쪽 벽에서 레펠
670
+ attachX = screenW;
671
+ attachY = y;
672
+ }
673
+
674
+ thread = {
675
+ attachX: attachX,
676
+ attachY: attachY,
677
+ length: CHAR_SIZE, // 초기 실 길이
678
+ angle: 0, // 진자 각도 (라디안)
679
+ swingVel: (Math.random() - 0.5) * 0.05, // 초기 흔들림 속도
680
+ };
681
+
682
+ movementMode = 'rappelling';
683
+ onSurface = false;
684
+ currentSurface = null;
685
+
686
+ if (typeof StateMachine !== 'undefined') {
687
+ StateMachine.forceState('rappelling');
688
+ }
689
+ }
690
+
691
+ /**
692
+ * 레펠 해제: 실을 놓아 자유 낙하 전환
693
+ */
694
+ function releaseThread() {
695
+ if (!thread) return;
696
+ thread = null;
697
+ vy = 0;
698
+ movementMode = 'falling';
699
+ onSurface = false;
700
+
701
+ if (typeof StateMachine !== 'undefined') {
702
+ StateMachine.forceState('falling');
703
+ }
704
+ }
705
+
706
+ /**
707
+ * 화면 중앙으로 이동
708
+ * 천장에서는 레펠로 하강, 그 외에는 점프
709
+ */
710
+ function moveToCenter() {
711
+ const cx = (screenW - CHAR_SIZE) / 2;
712
+ const cy = (screenH - CHAR_SIZE) / 2;
713
+
714
+ if (edge === 'top') {
715
+ // 천장에서는 레펠로 내려감
716
+ startRappel();
717
+ } else {
718
+ // 바닥/벽에서는 중앙으로 점프
719
+ jumpTo(cx, cy);
720
+ }
721
+ }
722
+
723
+ // ===================================
724
+ // 애니메이션 프레임 갱신
725
+ // ===================================
726
+
727
+ /**
728
+ * 현재 상태에 맞는 애니메이션 프레임 렌더링
729
+ * 공중 상태일 때는 기존 프레임셋을 재활용
730
+ *
731
+ * @param {string} state - StateMachine 상태
732
+ * @param {number} timestamp - requestAnimationFrame 타임스탬프
733
+ */
161
734
  function updateAnimation(state, timestamp) {
162
735
  if (timestamp - lastAnimTime > ANIM_INTERVAL) {
163
736
  animFrame++;
164
737
  lastAnimTime = timestamp;
165
738
  }
166
- const frameCount = Character.getFrameCount(state);
739
+
740
+ // 이동 모드에 따라 적절한 프레임셋으로 매핑
741
+ let effectiveState = state;
742
+ if (movementMode === 'jumping') effectiveState = 'jumping';
743
+ if (movementMode === 'falling') effectiveState = 'falling';
744
+ if (movementMode === 'rappelling') effectiveState = 'rappelling';
745
+
746
+ const frameCount = Character.getFrameCount(effectiveState);
167
747
  const currentFrame = animFrame % frameCount;
168
- Character.renderFrame(state, currentFrame, flipX);
748
+ Character.renderFrame(effectiveState, currentFrame, flipX);
169
749
  }
170
750
 
751
+ // ===================================
752
+ // 위치/상태 접근자
753
+ // ===================================
754
+
755
+ /**
756
+ * 현재 위치 및 상태 정보 반환
757
+ * @returns {{ x, y, edge, direction, flipX, movementMode, onSurface, thread }}
758
+ */
171
759
  function getPosition() {
172
- return { x, y, edge, direction, flipX };
760
+ return {
761
+ x, y, edge, direction, flipX,
762
+ movementMode, onSurface,
763
+ thread: thread ? true : false,
764
+ };
173
765
  }
174
766
 
767
+ /**
768
+ * 위치 직접 설정 (드래그 등)
769
+ */
175
770
  function setPosition(nx, ny) {
176
771
  x = nx;
177
772
  y = ny;
178
773
  clampPosition();
179
- updatePosition();
774
+ updateVisual();
180
775
  }
181
776
 
182
777
  function setEdge(newEdge) {
@@ -188,7 +783,10 @@ const PetEngine = (() => {
188
783
  flipX = dir < 0;
189
784
  }
190
785
 
191
- // 가장 가까운 가장자리로 이동 (드래그 후)
786
+ /**
787
+ * 가장 가까운 가장자리로 즉시 이동 (드래그 후)
788
+ * 모든 물리 상태를 초기화하고 표면에 붙음
789
+ */
192
790
  function snapToNearestEdge() {
193
791
  const distBottom = screenH - CHAR_SIZE - y;
194
792
  const distTop = y;
@@ -209,15 +807,57 @@ const PetEngine = (() => {
209
807
  x = screenW - CHAR_SIZE;
210
808
  edge = 'right';
211
809
  }
212
- updatePosition();
810
+
811
+ // 물리 상태 완전 초기화
812
+ movementMode = 'crawling';
813
+ onSurface = true;
814
+ currentSurface = null;
815
+ vx = 0;
816
+ vy = 0;
817
+ thread = null;
818
+
819
+ updateVisual();
820
+ }
821
+
822
+ /**
823
+ * 자유 낙하 시작 (화면 중앙 근처에서 놓았을 때)
824
+ * 중력에 의해 바닥 또는 가장 가까운 표면으로 떨어짐
825
+ */
826
+ function startFalling() {
827
+ movementMode = 'falling';
828
+ onSurface = false;
829
+ currentSurface = null;
830
+ vx = 0;
831
+ vy = 0;
832
+ thread = null;
833
+
834
+ if (typeof StateMachine !== 'undefined') {
835
+ StateMachine.forceState('falling');
836
+ }
837
+ }
838
+
839
+ /**
840
+ * 레펠 스레드 정보 반환
841
+ * @returns {object|null}
842
+ */
843
+ function getThread() {
844
+ return thread;
213
845
  }
214
846
 
847
+ // ===================================
848
+ // 메인 루프
849
+ // ===================================
850
+
215
851
  let frameId = null;
216
852
 
853
+ /**
854
+ * 엔진 시작: requestAnimationFrame 루프 가동
855
+ */
217
856
  function start() {
218
857
  if (running) return;
219
858
  running = true;
220
859
  lastAnimTime = performance.now();
860
+ lastStepTime = Date.now();
221
861
 
222
862
  function loop(timestamp) {
223
863
  if (!running) return;
@@ -229,13 +869,23 @@ const PetEngine = (() => {
229
869
  frameId = requestAnimationFrame(loop);
230
870
  }
231
871
 
872
+ /**
873
+ * 엔진 정지
874
+ */
232
875
  function stop() {
233
876
  running = false;
234
877
  if (frameId) cancelAnimationFrame(frameId);
235
878
  }
236
879
 
880
+ // --- 공개 API ---
237
881
  return {
238
- init, start, stop, getPosition, setPosition, setEdge, setDirection,
239
- snapToNearestEdge, setSpeedMultiplier, moveForState, CHAR_SIZE,
882
+ init, start, stop,
883
+ getPosition, setPosition, setEdge, setDirection,
884
+ snapToNearestEdge, setSpeedMultiplier,
885
+ moveForState, updateAnimation,
886
+ // 새 API: 물리 기반 이동
887
+ jumpTo, startRappel, releaseThread, moveToCenter,
888
+ setSurfaces, getThread, startFalling,
889
+ CHAR_SIZE,
240
890
  };
241
891
  })();