clawmate 1.0.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.
- package/electron-builder.yml +23 -0
- package/index.js +341 -0
- package/main/ai-bridge.js +261 -0
- package/main/ai-connector.js +169 -0
- package/main/autostart.js +42 -0
- package/main/desktop-path.js +33 -0
- package/main/file-ops.js +146 -0
- package/main/index.js +128 -0
- package/main/ipc-handlers.js +104 -0
- package/main/manifest.js +70 -0
- package/main/platform.js +33 -0
- package/main/store.js +45 -0
- package/main/tray.js +125 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +30 -0
- package/preload/preload.js +57 -0
- package/renderer/css/effects.css +87 -0
- package/renderer/css/launcher.css +127 -0
- package/renderer/css/pet.css +109 -0
- package/renderer/css/speech.css +72 -0
- package/renderer/first-run.html +90 -0
- package/renderer/index.html +62 -0
- package/renderer/js/ai-controller.js +206 -0
- package/renderer/js/app.js +107 -0
- package/renderer/js/character.js +396 -0
- package/renderer/js/interactions.js +164 -0
- package/renderer/js/memory.js +322 -0
- package/renderer/js/mode-manager.js +82 -0
- package/renderer/js/pet-engine.js +228 -0
- package/renderer/js/speech.js +116 -0
- package/renderer/js/state-machine.js +175 -0
- package/renderer/js/time-aware.js +93 -0
- package/renderer/launcher.html +58 -0
- package/shared/constants.js +80 -0
- package/shared/messages.js +103 -0
- package/shared/personalities.js +106 -0
- package/skills/launch-pet/index.js +64 -0
- package/skills/launch-pet/skill.json +26 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 사용자 상호작용 기억 + 진화 시스템
|
|
3
|
+
*
|
|
4
|
+
* - 클릭 횟수, 일수, 마일스톤 추적
|
|
5
|
+
* - 진화 단계 관리: 항상 긍정적/귀여운 방향으로만 진화
|
|
6
|
+
* - 무서운/끔찍한 모습으로는 절대 변하지 않음
|
|
7
|
+
*/
|
|
8
|
+
const Memory = (() => {
|
|
9
|
+
let data = {
|
|
10
|
+
totalClicks: 0,
|
|
11
|
+
totalDays: 0,
|
|
12
|
+
firstRunDate: null,
|
|
13
|
+
lastVisitDate: null,
|
|
14
|
+
milestones: [],
|
|
15
|
+
evolutionStage: 0,
|
|
16
|
+
interactionStreak: 0, // 연속 방문 일수
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let evolutionStages = null;
|
|
20
|
+
|
|
21
|
+
async function init() {
|
|
22
|
+
try {
|
|
23
|
+
const saved = await window.clawmate.getMemory();
|
|
24
|
+
if (saved) data = { ...data, ...saved };
|
|
25
|
+
} catch {}
|
|
26
|
+
|
|
27
|
+
evolutionStages = window._evolutionStages;
|
|
28
|
+
|
|
29
|
+
// 첫 실행
|
|
30
|
+
if (!data.firstRunDate) {
|
|
31
|
+
data.firstRunDate = new Date().toISOString();
|
|
32
|
+
await save();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 일수 계산
|
|
36
|
+
updateDayCount();
|
|
37
|
+
|
|
38
|
+
// 마일스톤 체크
|
|
39
|
+
checkMilestones();
|
|
40
|
+
|
|
41
|
+
// 진화 체크
|
|
42
|
+
checkEvolution();
|
|
43
|
+
|
|
44
|
+
// 진화 시각 효과 적용
|
|
45
|
+
applyEvolutionVisuals();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateDayCount() {
|
|
49
|
+
const firstRun = new Date(data.firstRunDate);
|
|
50
|
+
const now = new Date();
|
|
51
|
+
data.totalDays = Math.floor((now - firstRun) / (1000 * 60 * 60 * 24));
|
|
52
|
+
|
|
53
|
+
// 연속 방문 체크
|
|
54
|
+
const lastVisit = data.lastVisitDate ? new Date(data.lastVisitDate) : null;
|
|
55
|
+
const today = now.toDateString();
|
|
56
|
+
if (lastVisit && lastVisit.toDateString() !== today) {
|
|
57
|
+
const dayDiff = Math.floor((now - lastVisit) / (1000 * 60 * 60 * 24));
|
|
58
|
+
if (dayDiff === 1) {
|
|
59
|
+
data.interactionStreak++;
|
|
60
|
+
} else if (dayDiff > 1) {
|
|
61
|
+
data.interactionStreak = 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
data.lastVisitDate = now.toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordClick() {
|
|
68
|
+
data.totalClicks++;
|
|
69
|
+
checkMilestones();
|
|
70
|
+
checkEvolution();
|
|
71
|
+
save();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function checkMilestones() {
|
|
75
|
+
const milestoneChecks = [
|
|
76
|
+
{ key: 'first_click', condition: () => data.totalClicks >= 1 },
|
|
77
|
+
{ key: 'clicks_10', condition: () => data.totalClicks >= 10 },
|
|
78
|
+
{ key: 'clicks_50', condition: () => data.totalClicks >= 50 },
|
|
79
|
+
{ key: 'clicks_100', condition: () => data.totalClicks >= 100 },
|
|
80
|
+
{ key: 'clicks_500', condition: () => data.totalClicks >= 500 },
|
|
81
|
+
{ key: 'days_1', condition: () => data.totalDays >= 1 },
|
|
82
|
+
{ key: 'days_7', condition: () => data.totalDays >= 7 },
|
|
83
|
+
{ key: 'days_30', condition: () => data.totalDays >= 30 },
|
|
84
|
+
{ key: 'days_100', condition: () => data.totalDays >= 100 },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const check of milestoneChecks) {
|
|
88
|
+
if (!data.milestones.includes(check.key) && check.condition()) {
|
|
89
|
+
data.milestones.push(check.key);
|
|
90
|
+
const msg = Speech.getMilestoneMessage(check.key);
|
|
91
|
+
if (msg) {
|
|
92
|
+
// 약간의 딜레이 후 마일스톤 메시지 표시
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
Speech.show(msg);
|
|
95
|
+
Interactions.spawnStarEffect();
|
|
96
|
+
}, 1000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 진화 단계 체크
|
|
104
|
+
* 항상 올라가기만 함 (퇴화 없음)
|
|
105
|
+
* 조건: 클릭 횟수 + 함께한 일수 모두 충족
|
|
106
|
+
*/
|
|
107
|
+
function checkEvolution() {
|
|
108
|
+
if (!evolutionStages) return;
|
|
109
|
+
|
|
110
|
+
let newStage = data.evolutionStage;
|
|
111
|
+
|
|
112
|
+
for (let stage = 5; stage >= 0; stage--) {
|
|
113
|
+
const req = evolutionStages[stage];
|
|
114
|
+
if (!req) continue;
|
|
115
|
+
if (data.totalClicks >= req.clicksRequired && data.totalDays >= req.daysRequired) {
|
|
116
|
+
newStage = stage;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (newStage > data.evolutionStage) {
|
|
122
|
+
const prevStage = data.evolutionStage;
|
|
123
|
+
data.evolutionStage = newStage;
|
|
124
|
+
onEvolution(prevStage, newStage);
|
|
125
|
+
save();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 진화 발생 시 이벤트
|
|
131
|
+
* - 밝은 플래시 효과 (부드러운 빛)
|
|
132
|
+
* - 반짝이 파티클
|
|
133
|
+
* - 축하 메시지
|
|
134
|
+
*/
|
|
135
|
+
function onEvolution(prevStage, newStage) {
|
|
136
|
+
const msgs = window._messages;
|
|
137
|
+
const stageInfo = evolutionStages[newStage];
|
|
138
|
+
|
|
139
|
+
// 밝은 플래시 (무섭지 않은 부드러운 효과)
|
|
140
|
+
const flash = document.createElement('div');
|
|
141
|
+
flash.className = 'evolve-flash';
|
|
142
|
+
document.body.appendChild(flash);
|
|
143
|
+
setTimeout(() => flash.remove(), 600);
|
|
144
|
+
|
|
145
|
+
// 진화 반짝임 파티클 (밝은 색상만)
|
|
146
|
+
const pos = PetEngine.getPosition();
|
|
147
|
+
const sparkleColors = ['#FFD700', '#FF69B4', '#87CEEB', '#98FB98', '#DDA0DD'];
|
|
148
|
+
for (let i = 0; i < 16; i++) {
|
|
149
|
+
const sparkle = document.createElement('div');
|
|
150
|
+
sparkle.className = 'evolve-sparkle';
|
|
151
|
+
sparkle.style.backgroundColor = sparkleColors[i % sparkleColors.length];
|
|
152
|
+
sparkle.style.left = (pos.x + 32 + (Math.random() - 0.5) * 80) + 'px';
|
|
153
|
+
sparkle.style.top = (pos.y + 32 + (Math.random() - 0.5) * 80) + 'px';
|
|
154
|
+
document.getElementById('world').appendChild(sparkle);
|
|
155
|
+
setTimeout(() => sparkle.remove(), 800);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 진화 링 이펙트 (따뜻한 색)
|
|
159
|
+
const ring = document.createElement('div');
|
|
160
|
+
ring.className = 'evolve-ring';
|
|
161
|
+
ring.style.width = '64px';
|
|
162
|
+
ring.style.height = '64px';
|
|
163
|
+
ring.style.left = pos.x + 'px';
|
|
164
|
+
ring.style.top = pos.y + 'px';
|
|
165
|
+
ring.style.borderColor = '#FFD700';
|
|
166
|
+
document.getElementById('world').appendChild(ring);
|
|
167
|
+
setTimeout(() => ring.remove(), 1000);
|
|
168
|
+
|
|
169
|
+
// 축하 메시지
|
|
170
|
+
if (msgs && msgs.evolution) {
|
|
171
|
+
const evolveMsg = msgs.evolution[`stage_${newStage}`];
|
|
172
|
+
if (evolveMsg) {
|
|
173
|
+
setTimeout(() => Speech.show(evolveMsg), 800);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 시각 효과 업데이트
|
|
178
|
+
applyEvolutionVisuals();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 진화 단계에 따른 시각적 변화 적용
|
|
183
|
+
* 항상 긍정적: 밝아지고, 반짝이고, 귀여운 악세사리 추가
|
|
184
|
+
*/
|
|
185
|
+
function applyEvolutionVisuals() {
|
|
186
|
+
if (!evolutionStages) return;
|
|
187
|
+
const stage = evolutionStages[data.evolutionStage];
|
|
188
|
+
if (!stage) return;
|
|
189
|
+
|
|
190
|
+
const pet = document.getElementById('pet-container');
|
|
191
|
+
if (!pet) return;
|
|
192
|
+
|
|
193
|
+
// 크기 스케일
|
|
194
|
+
pet.style.transform = pet.style.transform || '';
|
|
195
|
+
|
|
196
|
+
// 밝기/채도 — 진화할수록 밝고 화사해짐
|
|
197
|
+
const { brightness, saturation } = stage.colorMod;
|
|
198
|
+
const canvas = pet.querySelector('canvas');
|
|
199
|
+
if (canvas) {
|
|
200
|
+
canvas.style.filter = `brightness(${brightness}) saturate(${saturation})`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 악세사리 제거 후 재적용
|
|
204
|
+
pet.querySelectorAll('.accessory').forEach(a => a.remove());
|
|
205
|
+
|
|
206
|
+
for (const acc of stage.accessories) {
|
|
207
|
+
addAccessory(pet, acc);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 귀여운 악세사리 추가
|
|
213
|
+
* 모든 악세사리는 밝고 귀여운 요소만
|
|
214
|
+
*/
|
|
215
|
+
function addAccessory(container, type) {
|
|
216
|
+
const acc = document.createElement('div');
|
|
217
|
+
acc.className = 'accessory';
|
|
218
|
+
acc.style.position = 'absolute';
|
|
219
|
+
acc.style.pointerEvents = 'none';
|
|
220
|
+
acc.style.zIndex = '1001';
|
|
221
|
+
|
|
222
|
+
switch (type) {
|
|
223
|
+
case 'blush':
|
|
224
|
+
// 양 볼에 핑크 동그라미
|
|
225
|
+
acc.style.width = '8px';
|
|
226
|
+
acc.style.height = '6px';
|
|
227
|
+
acc.style.borderRadius = '50%';
|
|
228
|
+
acc.style.background = 'rgba(255, 150, 150, 0.6)';
|
|
229
|
+
acc.style.left = '12px';
|
|
230
|
+
acc.style.top = '38px';
|
|
231
|
+
container.appendChild(acc);
|
|
232
|
+
// 오른쪽 볼
|
|
233
|
+
const blush2 = acc.cloneNode();
|
|
234
|
+
blush2.style.left = '44px';
|
|
235
|
+
container.appendChild(blush2);
|
|
236
|
+
return;
|
|
237
|
+
|
|
238
|
+
case 'sparkle_eyes':
|
|
239
|
+
// 눈에 반짝임 (흰색 작은 점)
|
|
240
|
+
acc.style.width = '3px';
|
|
241
|
+
acc.style.height = '3px';
|
|
242
|
+
acc.style.borderRadius = '50%';
|
|
243
|
+
acc.style.background = '#ffffff';
|
|
244
|
+
acc.style.left = '24px';
|
|
245
|
+
acc.style.top = '28px';
|
|
246
|
+
acc.style.boxShadow = '0 0 2px #fff';
|
|
247
|
+
container.appendChild(acc);
|
|
248
|
+
const sparkle2 = acc.cloneNode();
|
|
249
|
+
sparkle2.style.left = '40px';
|
|
250
|
+
container.appendChild(sparkle2);
|
|
251
|
+
return;
|
|
252
|
+
|
|
253
|
+
case 'crown':
|
|
254
|
+
acc.textContent = '\u{1F451}';
|
|
255
|
+
acc.style.fontSize = '12px';
|
|
256
|
+
acc.style.left = '22px';
|
|
257
|
+
acc.style.top = '-8px';
|
|
258
|
+
break;
|
|
259
|
+
|
|
260
|
+
case 'golden_crown':
|
|
261
|
+
acc.textContent = '\u{1F451}';
|
|
262
|
+
acc.style.fontSize = '14px';
|
|
263
|
+
acc.style.left = '20px';
|
|
264
|
+
acc.style.top = '-10px';
|
|
265
|
+
acc.style.filter = 'drop-shadow(0 0 3px gold)';
|
|
266
|
+
break;
|
|
267
|
+
|
|
268
|
+
case 'aura':
|
|
269
|
+
acc.style.width = '80px';
|
|
270
|
+
acc.style.height = '80px';
|
|
271
|
+
acc.style.borderRadius = '50%';
|
|
272
|
+
acc.style.left = '-8px';
|
|
273
|
+
acc.style.top = '-8px';
|
|
274
|
+
acc.style.background = 'radial-gradient(circle, rgba(255,215,0,0.15) 0%, transparent 70%)';
|
|
275
|
+
acc.style.animation = 'pulse-aura 2s ease-in-out infinite';
|
|
276
|
+
break;
|
|
277
|
+
|
|
278
|
+
case 'rainbow_aura':
|
|
279
|
+
acc.style.width = '90px';
|
|
280
|
+
acc.style.height = '90px';
|
|
281
|
+
acc.style.borderRadius = '50%';
|
|
282
|
+
acc.style.left = '-13px';
|
|
283
|
+
acc.style.top = '-13px';
|
|
284
|
+
acc.style.background = 'conic-gradient(from 0deg, rgba(255,0,0,0.1), rgba(255,165,0,0.1), rgba(255,255,0,0.1), rgba(0,128,0,0.1), rgba(0,0,255,0.1), rgba(128,0,128,0.1), rgba(255,0,0,0.1))';
|
|
285
|
+
acc.style.animation = 'spin-slow 8s linear infinite';
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case 'wings':
|
|
289
|
+
// 작은 천사 날개 (왼쪽)
|
|
290
|
+
acc.textContent = '\u{1FABD}';
|
|
291
|
+
acc.style.fontSize = '10px';
|
|
292
|
+
acc.style.left = '-6px';
|
|
293
|
+
acc.style.top = '20px';
|
|
294
|
+
acc.style.opacity = '0.7';
|
|
295
|
+
container.appendChild(acc);
|
|
296
|
+
// 오른쪽 날개
|
|
297
|
+
const wing2 = acc.cloneNode(true);
|
|
298
|
+
wing2.style.left = '58px';
|
|
299
|
+
wing2.style.transform = 'scaleX(-1)';
|
|
300
|
+
container.appendChild(wing2);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
container.appendChild(acc);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function save() {
|
|
308
|
+
try {
|
|
309
|
+
await window.clawmate.saveMemory(data);
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getData() {
|
|
314
|
+
return { ...data };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getEvolutionStage() {
|
|
318
|
+
return data.evolutionStage;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { init, recordClick, getData, getEvolutionStage, save };
|
|
322
|
+
})();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet ↔ Incarnation 모드 전환 관리
|
|
3
|
+
*/
|
|
4
|
+
const ModeManager = (() => {
|
|
5
|
+
let currentMode = 'pet';
|
|
6
|
+
|
|
7
|
+
async function init() {
|
|
8
|
+
currentMode = await window.clawmate.getMode() || 'pet';
|
|
9
|
+
applyMode(currentMode);
|
|
10
|
+
|
|
11
|
+
window.clawmate.onModeChanged((mode) => {
|
|
12
|
+
currentMode = mode;
|
|
13
|
+
applyMode(mode);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function applyMode(mode) {
|
|
18
|
+
const pet = document.getElementById('pet-container');
|
|
19
|
+
const personalities = window._personalities;
|
|
20
|
+
if (!personalities) return;
|
|
21
|
+
|
|
22
|
+
const p = personalities[mode];
|
|
23
|
+
if (!p) return;
|
|
24
|
+
|
|
25
|
+
// 캐릭터 색상 업데이트
|
|
26
|
+
const colors = mode === 'pet'
|
|
27
|
+
? { primary: '#ff4f40', secondary: '#ff775f', dark: '#3a0a0d', eye: '#ffffff', pupil: '#111111', claw: '#ff4f40' }
|
|
28
|
+
: { primary: '#ff4f40', secondary: '#ff775f', dark: '#3a0a0d', eye: '#00BFA5', pupil: '#004D40', claw: '#ff4f40' };
|
|
29
|
+
Character.setColorMap(colors);
|
|
30
|
+
|
|
31
|
+
// 속도 조정
|
|
32
|
+
PetEngine.setSpeedMultiplier(p.speedMultiplier);
|
|
33
|
+
|
|
34
|
+
// CSS 클래스
|
|
35
|
+
pet.classList.remove('mode-pet', 'mode-incarnation');
|
|
36
|
+
pet.classList.add(`mode-${mode}`);
|
|
37
|
+
|
|
38
|
+
// 말풍선 스타일
|
|
39
|
+
Speech.setMode(mode);
|
|
40
|
+
|
|
41
|
+
// 성격 적용
|
|
42
|
+
StateMachine.setPersonality(p);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function toggle() {
|
|
46
|
+
const newMode = currentMode === 'pet' ? 'incarnation' : 'pet';
|
|
47
|
+
await window.clawmate.setMode(newMode);
|
|
48
|
+
currentMode = newMode;
|
|
49
|
+
applyMode(newMode);
|
|
50
|
+
spawnTransitionEffect(newMode);
|
|
51
|
+
Speech.show(newMode === 'pet'
|
|
52
|
+
? 'Clawby 모드로 변신!'
|
|
53
|
+
: 'OpenClaw... 각성했다.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function spawnTransitionEffect(mode) {
|
|
57
|
+
const pos = PetEngine.getPosition();
|
|
58
|
+
const color = mode === 'pet' ? '#ff4f40' : '#00BFA5';
|
|
59
|
+
const world = document.getElementById('world');
|
|
60
|
+
|
|
61
|
+
// 파티클 버스트
|
|
62
|
+
for (let i = 0; i < 12; i++) {
|
|
63
|
+
const p = document.createElement('div');
|
|
64
|
+
p.className = 'mode-transition-particle';
|
|
65
|
+
p.style.backgroundColor = color;
|
|
66
|
+
p.style.left = (pos.x + 32) + 'px';
|
|
67
|
+
p.style.top = (pos.y + 32) + 'px';
|
|
68
|
+
const angle = (Math.PI * 2 * i) / 12;
|
|
69
|
+
const dist = 30 + Math.random() * 40;
|
|
70
|
+
p.style.setProperty('--px', Math.cos(angle) * dist + 'px');
|
|
71
|
+
p.style.setProperty('--py', Math.sin(angle) * dist + 'px');
|
|
72
|
+
world.appendChild(p);
|
|
73
|
+
setTimeout(() => p.remove(), 800);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getMode() {
|
|
78
|
+
return currentMode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { init, toggle, getMode, applyMode };
|
|
82
|
+
})();
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 핵심 이동/애니메이션 엔진
|
|
3
|
+
* requestAnimationFrame 기반 이동 루프 — 화면 4면 가장자리 이동
|
|
4
|
+
*/
|
|
5
|
+
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
|
|
10
|
+
|
|
11
|
+
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;
|
|
17
|
+
let running = false;
|
|
18
|
+
let animFrame = 0;
|
|
19
|
+
let lastAnimTime = 0;
|
|
20
|
+
let speedMultiplier = 1.0;
|
|
21
|
+
let petContainer = null;
|
|
22
|
+
|
|
23
|
+
function init(container) {
|
|
24
|
+
petContainer = container;
|
|
25
|
+
// 화면 하단 중앙에서 시작
|
|
26
|
+
screenW = window.innerWidth;
|
|
27
|
+
screenH = window.innerHeight;
|
|
28
|
+
x = (screenW - CHAR_SIZE) / 2;
|
|
29
|
+
y = screenH - CHAR_SIZE;
|
|
30
|
+
edge = 'bottom';
|
|
31
|
+
direction = 1;
|
|
32
|
+
updatePosition();
|
|
33
|
+
|
|
34
|
+
window.addEventListener('resize', () => {
|
|
35
|
+
screenW = window.innerWidth;
|
|
36
|
+
screenH = window.innerHeight;
|
|
37
|
+
clampPosition();
|
|
38
|
+
updatePosition();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setSpeedMultiplier(mult) {
|
|
43
|
+
speedMultiplier = mult;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clampPosition() {
|
|
47
|
+
x = Math.max(0, Math.min(x, screenW - CHAR_SIZE));
|
|
48
|
+
y = Math.max(0, Math.min(y, screenH - CHAR_SIZE));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function updatePosition() {
|
|
52
|
+
if (!petContainer) return;
|
|
53
|
+
petContainer.style.left = x + 'px';
|
|
54
|
+
petContainer.style.top = y + 'px';
|
|
55
|
+
|
|
56
|
+
// 천장에 있을 때는 캐릭터를 뒤집음
|
|
57
|
+
let transform = '';
|
|
58
|
+
if (flipX) transform += 'scaleX(-1) ';
|
|
59
|
+
if (edge === 'top') transform += 'scaleY(-1) ';
|
|
60
|
+
if (edge === 'left' || edge === 'right') transform += 'rotate(90deg) ';
|
|
61
|
+
petContainer.style.transform = transform.trim() || 'none';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function moveForState(state) {
|
|
65
|
+
const speed = BASE_SPEED * speedMultiplier;
|
|
66
|
+
const climbSpeed = CLIMB_SPEED * speedMultiplier;
|
|
67
|
+
|
|
68
|
+
switch (state) {
|
|
69
|
+
case 'walking':
|
|
70
|
+
if (edge === 'bottom' || edge === 'top') {
|
|
71
|
+
x += speed * direction;
|
|
72
|
+
flipX = direction < 0;
|
|
73
|
+
// 벽에 닿으면 방향 전환
|
|
74
|
+
if (x <= 0) { x = 0; direction = 1; }
|
|
75
|
+
if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case 'ceiling_walk':
|
|
80
|
+
if (edge === 'top') {
|
|
81
|
+
x += speed * direction;
|
|
82
|
+
flipX = direction < 0;
|
|
83
|
+
if (x <= 0) { x = 0; direction = 1; }
|
|
84
|
+
if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
case 'climbing_up':
|
|
89
|
+
if (edge === 'bottom') {
|
|
90
|
+
// 바닥에서 벽으로
|
|
91
|
+
if (direction > 0) {
|
|
92
|
+
x = screenW - CHAR_SIZE;
|
|
93
|
+
edge = 'right';
|
|
94
|
+
} else {
|
|
95
|
+
x = 0;
|
|
96
|
+
edge = 'left';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
y -= climbSpeed;
|
|
100
|
+
if (y <= 0) {
|
|
101
|
+
y = 0;
|
|
102
|
+
edge = 'top';
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case 'climbing_down':
|
|
107
|
+
y += climbSpeed;
|
|
108
|
+
if (y >= screenH - CHAR_SIZE) {
|
|
109
|
+
y = screenH - CHAR_SIZE;
|
|
110
|
+
edge = 'bottom';
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case 'scared':
|
|
115
|
+
// 빠르게 도망
|
|
116
|
+
x += speed * 2.5 * direction;
|
|
117
|
+
flipX = direction < 0;
|
|
118
|
+
if (x <= 0) { x = 0; direction = 1; }
|
|
119
|
+
if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case 'carrying':
|
|
123
|
+
x += speed * 0.7 * direction;
|
|
124
|
+
flipX = direction < 0;
|
|
125
|
+
if (x <= 0) { x = 0; direction = 1; }
|
|
126
|
+
if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
|
|
127
|
+
break;
|
|
128
|
+
|
|
129
|
+
case 'excited':
|
|
130
|
+
// 작은 점프 효과
|
|
131
|
+
const elapsed = StateMachine.getElapsed();
|
|
132
|
+
const jumpOffset = Math.sin(elapsed / 150) * 8;
|
|
133
|
+
y = (screenH - CHAR_SIZE) + jumpOffset;
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
case 'idle':
|
|
137
|
+
case 'sleeping':
|
|
138
|
+
case 'interacting':
|
|
139
|
+
case 'playing':
|
|
140
|
+
// 정지 또는 미세한 흔들림
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clampPosition();
|
|
145
|
+
updatePosition();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function updateAnimation(state, timestamp) {
|
|
149
|
+
if (timestamp - lastAnimTime > ANIM_INTERVAL) {
|
|
150
|
+
animFrame++;
|
|
151
|
+
lastAnimTime = timestamp;
|
|
152
|
+
}
|
|
153
|
+
const frameCount = Character.getFrameCount(state);
|
|
154
|
+
const currentFrame = animFrame % frameCount;
|
|
155
|
+
Character.renderFrame(state, currentFrame, flipX);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getPosition() {
|
|
159
|
+
return { x, y, edge, direction, flipX };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function setPosition(nx, ny) {
|
|
163
|
+
x = nx;
|
|
164
|
+
y = ny;
|
|
165
|
+
clampPosition();
|
|
166
|
+
updatePosition();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setEdge(newEdge) {
|
|
170
|
+
edge = newEdge;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setDirection(dir) {
|
|
174
|
+
direction = dir;
|
|
175
|
+
flipX = dir < 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 가장 가까운 가장자리로 이동 (드래그 후)
|
|
179
|
+
function snapToNearestEdge() {
|
|
180
|
+
const distBottom = screenH - CHAR_SIZE - y;
|
|
181
|
+
const distTop = y;
|
|
182
|
+
const distLeft = x;
|
|
183
|
+
const distRight = screenW - CHAR_SIZE - x;
|
|
184
|
+
const minDist = Math.min(distBottom, distTop, distLeft, distRight);
|
|
185
|
+
|
|
186
|
+
if (minDist === distBottom) {
|
|
187
|
+
y = screenH - CHAR_SIZE;
|
|
188
|
+
edge = 'bottom';
|
|
189
|
+
} else if (minDist === distTop) {
|
|
190
|
+
y = 0;
|
|
191
|
+
edge = 'top';
|
|
192
|
+
} else if (minDist === distLeft) {
|
|
193
|
+
x = 0;
|
|
194
|
+
edge = 'left';
|
|
195
|
+
} else {
|
|
196
|
+
x = screenW - CHAR_SIZE;
|
|
197
|
+
edge = 'right';
|
|
198
|
+
}
|
|
199
|
+
updatePosition();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let frameId = null;
|
|
203
|
+
|
|
204
|
+
function start() {
|
|
205
|
+
if (running) return;
|
|
206
|
+
running = true;
|
|
207
|
+
lastAnimTime = performance.now();
|
|
208
|
+
|
|
209
|
+
function loop(timestamp) {
|
|
210
|
+
if (!running) return;
|
|
211
|
+
const state = StateMachine.update();
|
|
212
|
+
moveForState(state);
|
|
213
|
+
updateAnimation(state, timestamp);
|
|
214
|
+
frameId = requestAnimationFrame(loop);
|
|
215
|
+
}
|
|
216
|
+
frameId = requestAnimationFrame(loop);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stop() {
|
|
220
|
+
running = false;
|
|
221
|
+
if (frameId) cancelAnimationFrame(frameId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
init, start, stop, getPosition, setPosition, setEdge, setDirection,
|
|
226
|
+
snapToNearestEdge, setSpeedMultiplier, moveForState, CHAR_SIZE,
|
|
227
|
+
};
|
|
228
|
+
})();
|