novac 2.3.0 → 2.4.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.
@@ -0,0 +1,752 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kitpet — Persistent terminal pet (Tamagotchi-style)
5
+ *
6
+ * Your pet lives on disk between sessions. It gets hungry, bored, tired,
7
+ * and dirty if you neglect it. Take care of it and it levels up.
8
+ *
9
+ * kitdef API (same contract as kitarcade):
10
+ * .start() load or hatch a new pet
11
+ * .input(key) 'f'=feed 'p'=play 's'=sleep 'b'=bath 'm'=medicine
12
+ * 'n'=name 'enter'/'space'=interact 'escape'=cancel
13
+ * .tick(dt) advance time (pass real dt; pet also ages on real-world time)
14
+ * .frame() → ANSI string
15
+ * .state → 'idle'|'playing'|'sleeping'|'sick'|'dead'|'hatching'
16
+ * .score → pet happiness score
17
+ * .on(ev, fn) events: 'levelup' 'death' 'hatch' 'score'
18
+ *
19
+ * Quick-start:
20
+ * const { kitdef } = require('./kitpet');
21
+ * const { RunGame } = require('./run_game');
22
+ * RunGame(kitdef.Pet({ savePath: './mypet.json' }));
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const os = require('os');
28
+
29
+ // ─── ANSI UTILS ──────────────────────────────────────────────────────────────
30
+
31
+ const FC = {
32
+ blk:30,red:31,grn:32,yel:33,blu:34,mag:35,cyn:36,wht:37,
33
+ BLK:90,RED:91,GRN:92,YEL:93,BLU:94,MAG:95,CYN:96,WHT:97,
34
+ };
35
+ const BC = {
36
+ blk:40,red:41,grn:42,yel:43,blu:44,mag:45,cyn:46,wht:47,
37
+ BLK:100,RED:101,GRN:102,YEL:103,BLU:104,MAG:105,CYN:106,WHT:107,
38
+ };
39
+ const RST = '\x1b[0m';
40
+ const BLD = '\x1b[1m';
41
+
42
+ function fc(c, s) { return `\x1b[${c}m${s}${RST}`; }
43
+ function bc(c, s) { return `\x1b[${c}m${s}${RST}`; }
44
+ function bold(s) { return `${BLD}${s}${RST}`; }
45
+
46
+ function makeCanvas(W, H) {
47
+ const ch = Array.from({length:H}, () => new Array(W).fill(' '));
48
+ const fg = Array.from({length:H}, () => new Array(W).fill(FC.wht));
49
+ const bg = Array.from({length:H}, () => new Array(W).fill(BC.blk));
50
+ const cv = {
51
+ W, H,
52
+ clear(c=' ', f=FC.wht, b=BC.blk) {
53
+ for (let y=0;y<H;y++) for (let x=0;x<W;x++) { ch[y][x]=c;fg[y][x]=f;bg[y][x]=b; }
54
+ },
55
+ set(x, y, c, f=FC.wht, b=BC.blk) {
56
+ if (x<0||x>=W||y<0||y>=H) return;
57
+ ch[y][x]=(String(c)[0])||' '; fg[y][x]=f; bg[y][x]=b;
58
+ },
59
+ put(x, y, s, f=FC.wht, b=BC.blk) {
60
+ for (let i=0;i<s.length;i++) cv.set(x+i, y, s[i], f, b);
61
+ },
62
+ fill(x, y, w, h, c, f=FC.wht, b=BC.blk) {
63
+ for (let dy=0;dy<h;dy++) for (let dx=0;dx<w;dx++) cv.set(x+dx,y+dy,c,f,b);
64
+ },
65
+ box(x, y, w, h, f=FC.wht, b=BC.blk) {
66
+ cv.put(x, y, '┌'+'─'.repeat(w-2)+'┐', f, b);
67
+ for (let dy=1;dy<h-1;dy++) { cv.set(x,y+dy,'│',f,b); cv.set(x+w-1,y+dy,'│',f,b); }
68
+ cv.put(x, y+h-1, '└'+'─'.repeat(w-2)+'┘', f, b);
69
+ },
70
+ render() {
71
+ const lines = [];
72
+ for (let y=0;y<H;y++) {
73
+ let row='', cf=-1, cb=-1;
74
+ for (let x=0;x<W;x++) {
75
+ const f=fg[y][x], b=bg[y][x];
76
+ if (f!==cf||b!==cb) { row+=`\x1b[${f};${b}m`; cf=f; cb=b; }
77
+ row+=ch[y][x];
78
+ }
79
+ lines.push(row+RST);
80
+ }
81
+ return lines.join('\n');
82
+ },
83
+ };
84
+ return cv;
85
+ }
86
+
87
+ function makeEmitter() {
88
+ const L = new Map();
89
+ return {
90
+ on(ev,fn) { if(!L.has(ev)) L.set(ev,[]); L.get(ev).push(fn); },
91
+ off(ev,fn) { if(L.has(ev)) L.set(ev,L.get(ev).filter(f=>f!==fn)); },
92
+ emit(ev,...a) { (L.get(ev)??[]).forEach(fn=>fn(...a)); },
93
+ };
94
+ }
95
+
96
+ const rng = n => Math.random()*n|0;
97
+ const clamp = (x,lo,hi) => Math.max(lo,Math.min(hi,x));
98
+ const bar = (val, max, len, filledChar='█', emptyChar='░') => {
99
+ const filled = Math.round(val/max*len);
100
+ return filledChar.repeat(filled) + emptyChar.repeat(len-filled);
101
+ };
102
+
103
+ // ─── PET SPRITES ─────────────────────────────────────────────────────────────
104
+ // Each sprite is an array of lines. Multiple frames for animation.
105
+
106
+ const SPRITES = {
107
+ egg: [
108
+ [
109
+ ' .~~~. ',
110
+ ' / \\ ',
111
+ ' | ~~~ | ',
112
+ ' | | ',
113
+ ' \\ ._. / ',
114
+ ' \\___/ ',
115
+ ],
116
+ ],
117
+ happy: [
118
+ [
119
+ ' (=^.^=) ',
120
+ ' ( )',
121
+ ' ( u u )',
122
+ ' \\ / ',
123
+ ' /| |\\ ',
124
+ ' (_| |_)',
125
+ ],
126
+ [
127
+ ' (=^.^=) ',
128
+ ' ( )',
129
+ ' ( u u )',
130
+ ' \\ / ',
131
+ ' \\(| |)/',
132
+ ' | | ',
133
+ ],
134
+ ],
135
+ idle: [
136
+ [
137
+ ' (-_- ) ',
138
+ ' ( ) ',
139
+ ' ( o o ) ',
140
+ ' \\ / ',
141
+ ' /| |\\ ',
142
+ ' (_| |_)',
143
+ ],
144
+ [
145
+ ' (-_- ) ',
146
+ ' ( ) ',
147
+ ' ( - - ) ',
148
+ ' \\ / ',
149
+ ' /| |\\ ',
150
+ ' (_| |_)',
151
+ ],
152
+ ],
153
+ eating: [
154
+ [
155
+ ' (^o^ ) ',
156
+ ' ( ) ',
157
+ ' ( o o ) ',
158
+ ' \\ / ',
159
+ ' /| nom |\\ ',
160
+ ' (_| |_)',
161
+ ],
162
+ [
163
+ ' (^O^ ) ',
164
+ ' ( ) ',
165
+ ' ( o o ) ',
166
+ ' \\ / ',
167
+ ' /| NOM |\\ ',
168
+ ' (_| |_)',
169
+ ],
170
+ ],
171
+ playing: [
172
+ [
173
+ ' (^ ^ ) ',
174
+ ' ( ) ',
175
+ ' ( ^ ^ ) ',
176
+ ' \\ / ',
177
+ ' \\(| |)/',
178
+ ' \\ / ',
179
+ ],
180
+ [
181
+ ' ( ^ ^) ',
182
+ ' ( ) ',
183
+ ' ( ^ ^ ) ',
184
+ ' \\ / ',
185
+ '/(| |)\\',
186
+ ' \\ / ',
187
+ ],
188
+ ],
189
+ sleeping: [
190
+ [
191
+ ' (-_- ) ',
192
+ ' ( zzz ',
193
+ ' ( - - ) ',
194
+ ' \\ / ',
195
+ ' /| |\\ ',
196
+ ' (_|_____|_)',
197
+ ],
198
+ [
199
+ ' (-_- ) ',
200
+ ' ( Zzz ',
201
+ ' ( - - ) ',
202
+ ' \\ / ',
203
+ ' /| |\\ ',
204
+ ' (_|_____|_)',
205
+ ],
206
+ ],
207
+ sick: [
208
+ [
209
+ ' (@_@ ) ',
210
+ ' ( ) ',
211
+ ' ( x x ) ',
212
+ ' \\ / ',
213
+ ' /| ~ |\\ ',
214
+ ' (_| |_)',
215
+ ],
216
+ ],
217
+ dirty: [
218
+ [
219
+ ' (*_* ) ',
220
+ ' ( . ) ',
221
+ ' ( o o ) ',
222
+ ' \\ . . / ',
223
+ ' /| . . |\\ ',
224
+ ' (_| . |_)',
225
+ ],
226
+ ],
227
+ dead: [
228
+ [
229
+ ' (x_x ) ',
230
+ ' ( ) ',
231
+ ' ( x x ) ',
232
+ ' \\ / ',
233
+ ' /| |\\ ',
234
+ ' (_| |_)',
235
+ ],
236
+ ],
237
+ bathing: [
238
+ [
239
+ ' (o_o ) ',
240
+ ' ~( )~',
241
+ ' ~( o o )~ ',
242
+ ' ~ \\ / ~ ',
243
+ '~~/| |\\~~',
244
+ ' (_ _) ',
245
+ ],
246
+ [
247
+ ' (^_^ ) ',
248
+ '~~ ( )~~',
249
+ '~~ ( o o)~~',
250
+ '~~ \\ /~~',
251
+ '~~/| |\\~~',
252
+ ' (_ _) ',
253
+ ],
254
+ ],
255
+ };
256
+
257
+ // ─── FOOD / ITEM TABLES ───────────────────────────────────────────────────────
258
+
259
+ const FOODS = [
260
+ { name: 'Apple', hunger: 20, happy: 5, icon: '🍎' },
261
+ { name: 'Pizza', hunger: 40, happy: 15, icon: '🍕' },
262
+ { name: 'Cake', hunger: 30, happy: 25, icon: '🎂' },
263
+ { name: 'Salad', hunger: 15, happy: 2, icon: '🥗' },
264
+ { name: 'Ramen', hunger: 45, happy: 20, icon: '🍜' },
265
+ { name: 'Donut', hunger: 25, happy: 30, icon: '🍩' },
266
+ ];
267
+
268
+ const GAMES_LIST = [
269
+ { name: 'Fetch', happy: 20, energy: -15, icon: '🎾' },
270
+ { name: 'Chase', happy: 25, energy: -20, icon: '🏃' },
271
+ { name: 'Puzzle', happy: 15, energy: -10, icon: '🧩' },
272
+ { name: 'Dance', happy: 30, energy: -25, icon: '💃' },
273
+ ];
274
+
275
+ const NAMES = [
276
+ 'Byte','Pixel','Noodle','Mochi','Glitch','Boop','Fuzz','Blob',
277
+ 'Chip','Widget','Snoot','Doodle','Kiwi','Pebble','Ziggy','Flux',
278
+ ];
279
+
280
+ // ─── PERSISTENCE ─────────────────────────────────────────────────────────────
281
+
282
+ function defaultSavePath(opts) {
283
+ return opts?.savePath ?? path.join(os.homedir(), '.kitpet.json');
284
+ }
285
+
286
+ function loadPet(savePath) {
287
+ try {
288
+ const raw = fs.readFileSync(savePath, 'utf8');
289
+ return JSON.parse(raw);
290
+ } catch { return null; }
291
+ }
292
+
293
+ function savePet(savePath, data) {
294
+ try { fs.writeFileSync(savePath, JSON.stringify(data, null, 2), 'utf8'); } catch {}
295
+ }
296
+
297
+ // ─── OFFLINE AGING ───────────────────────────────────────────────────────────
298
+ // When you re-open kitpet after being away, real time has passed.
299
+ // We simulate what happened (hunger/boredom/dirtiness) at a compressed rate.
300
+
301
+ function applyOfflineTime(pet) {
302
+ const now = Date.now();
303
+ const elapsed = Math.min((now - pet.lastSeen) / 1000, 3600 * 8); // cap at 8 hrs
304
+ if (elapsed < 10) return;
305
+
306
+ // rates per second (offline is gentler so you don't always come back to a dead pet)
307
+ const hungerRate = 0.5; // hunger points per second
308
+ const boredRate = 0.3;
309
+ const dirtyRate = 0.2;
310
+ const energyRate = -0.1; // energy trickles back while resting
311
+
312
+ pet.hunger = clamp(pet.hunger - elapsed * hungerRate, 0, 100);
313
+ pet.happy = clamp(pet.happy - elapsed * boredRate, 0, 100);
314
+ pet.clean = clamp(pet.clean - elapsed * dirtyRate, 0, 100);
315
+ pet.energy = clamp(pet.energy + elapsed * energyRate, 0, 100);
316
+ pet.age += elapsed / 60; // age in minutes
317
+
318
+ pet.offlineMsg = `Away ${(elapsed/60).toFixed(0)}m — hunger, mood, cleanliness dropped.`;
319
+ }
320
+
321
+ // ─── MAKE PET ─────────────────────────────────────────────────────────────────
322
+
323
+ function makePet(opts = {}) {
324
+ const savePath = defaultSavePath(opts);
325
+ const em = makeEmitter();
326
+
327
+ // ── state ──────────────────────────────────────────────────────────────────
328
+
329
+ let pet; // the persisted data object
330
+ let state; // current game state string
331
+ let animFrame; // current sprite frame index
332
+ let animTimer; // ms until next frame flip
333
+ let tickElapsed; // accumulator for stat decay
334
+ let msgQueue; // [ { text, ttl } ] — status messages
335
+ let subMenu; // null | { type:'food'|'play'|'name', idx:0 }
336
+ let inputBuf; // for name entry
337
+ let score; // happiness score (accumulates over life)
338
+ let frameCount;
339
+
340
+ function newPetData() {
341
+ return {
342
+ name: NAMES[rng(NAMES.length)],
343
+ species: ['Cat','Dog','Dragon','Bunny','Ghost'][rng(5)],
344
+ hunger: 80, // 0=starving 100=full
345
+ happy: 80, // 0=sad 100=ecstatic
346
+ energy: 80, // 0=exhausted 100=rested
347
+ clean: 90, // 0=filthy 100=spotless
348
+ health: 100, // 0=dead 100=perfect
349
+ age: 0, // minutes since hatch
350
+ level: 1,
351
+ xp: 0,
352
+ xpNext: 100,
353
+ hatchTime: Date.now(),
354
+ lastSeen: Date.now(),
355
+ totalScore: 0,
356
+ alive: true,
357
+ hatched: false,
358
+ };
359
+ }
360
+
361
+ function start() {
362
+ const saved = loadPet(savePath);
363
+ if (saved && saved.alive) {
364
+ pet = saved;
365
+ applyOfflineTime(pet);
366
+ if (pet.offlineMsg) { pushMsg(pet.offlineMsg, 4000); }
367
+ } else {
368
+ pet = newPetData();
369
+ pushMsg(`${pet.name} the ${pet.species} has hatched! 🥚`, 3000);
370
+ pet.hatched = true;
371
+ em.emit('hatch', pet);
372
+ }
373
+ state = pet.hatched ? (pet.alive ? 'idle' : 'dead') : 'hatching';
374
+ animFrame = 0;
375
+ animTimer = 500;
376
+ tickElapsed = 0;
377
+ msgQueue = [];
378
+ subMenu = null;
379
+ inputBuf = '';
380
+ score = pet.totalScore;
381
+ frameCount = 0;
382
+ }
383
+
384
+ function save() {
385
+ pet.lastSeen = Date.now();
386
+ pet.totalScore = score;
387
+ savePet(savePath, pet);
388
+ }
389
+
390
+ // ── messages ───────────────────────────────────────────────────────────────
391
+
392
+ function pushMsg(text, ttl = 2000) {
393
+ msgQueue.unshift({ text, ttl });
394
+ if (msgQueue.length > 5) msgQueue.pop();
395
+ }
396
+
397
+ // ── stat helpers ───────────────────────────────────────────────────────────
398
+
399
+ function gainXP(n) {
400
+ pet.xp += n;
401
+ while (pet.xp >= pet.xpNext) {
402
+ pet.xp -= pet.xpNext;
403
+ pet.level += 1;
404
+ pet.xpNext = pet.level * 100;
405
+ pushMsg(`⬆️ ${pet.name} reached level ${pet.level}!`, 3000);
406
+ em.emit('levelup', pet.level);
407
+ }
408
+ }
409
+
410
+ function currentSprite() {
411
+ if (!pet.hatched) return 'egg';
412
+ if (!pet.alive) return 'dead';
413
+ if (state === 'sleeping') return 'sleeping';
414
+ if (state === 'eating') return 'eating';
415
+ if (state === 'playing') return 'playing';
416
+ if (state === 'bathing') return 'bathing';
417
+ if (pet.health < 30) return 'sick';
418
+ if (pet.clean < 30) return 'dirty';
419
+ if (pet.happy > 70) return 'happy';
420
+ return 'idle';
421
+ }
422
+
423
+ // ── actions ────────────────────────────────────────────────────────────────
424
+
425
+ function feed(foodIdx) {
426
+ const food = FOODS[foodIdx ?? rng(FOODS.length)];
427
+ if (pet.hunger >= 95) { pushMsg(`${pet.name} is already full!`); return; }
428
+ pet.hunger = clamp(pet.hunger + food.hunger, 0, 100);
429
+ pet.happy = clamp(pet.happy + food.happy, 0, 100);
430
+ gainXP(10);
431
+ score += food.happy;
432
+ em.emit('score', score);
433
+ pushMsg(`${pet.name} ate ${food.icon} ${food.name}! Yum!`);
434
+ state = 'eating';
435
+ setTimeout(() => { if (state === 'eating') state = 'idle'; }, 1500);
436
+ save();
437
+ }
438
+
439
+ function play(gameIdx) {
440
+ const g = GAMES_LIST[gameIdx ?? rng(GAMES_LIST.length)];
441
+ if (pet.energy < 20) { pushMsg(`${pet.name} is too tired to play!`); return; }
442
+ if (pet.health < 20) { pushMsg(`${pet.name} is too sick to play!`); return; }
443
+ pet.happy = clamp(pet.happy + g.happy, 0, 100);
444
+ pet.energy = clamp(pet.energy + g.energy, 0, 100);
445
+ gainXP(15);
446
+ score += g.happy;
447
+ em.emit('score', score);
448
+ pushMsg(`${pet.name} played ${g.icon} ${g.name}! So fun!`);
449
+ state = 'playing';
450
+ setTimeout(() => { if (state === 'playing') state = 'idle'; }, 2000);
451
+ save();
452
+ }
453
+
454
+ function sleep() {
455
+ if (state === 'sleeping') {
456
+ pushMsg(`${pet.name} woke up!`);
457
+ state = 'idle'; save(); return;
458
+ }
459
+ pushMsg(`${pet.name} is going to sleep... 💤`);
460
+ state = 'sleeping';
461
+ save();
462
+ }
463
+
464
+ function bathe() {
465
+ if (pet.clean >= 95) { pushMsg(`${pet.name} is already clean!`); return; }
466
+ pet.clean = 100;
467
+ pet.health = clamp(pet.health + 10, 0, 100);
468
+ gainXP(8);
469
+ score += 10;
470
+ pushMsg(`${pet.name} had a bath! Squeaky clean! 🛁`);
471
+ state = 'bathing';
472
+ setTimeout(() => { if (state === 'bathing') state = 'idle'; }, 2000);
473
+ save();
474
+ }
475
+
476
+ function medicine() {
477
+ if (pet.health >= 90) { pushMsg(`${pet.name} doesn't need medicine.`); return; }
478
+ pet.health = clamp(pet.health + 30, 0, 100);
479
+ gainXP(5);
480
+ pushMsg(`${pet.name} took medicine! 💊 Feeling better.`);
481
+ save();
482
+ }
483
+
484
+ function rename(newName) {
485
+ const old = pet.name;
486
+ pet.name = newName || NAMES[rng(NAMES.length)];
487
+ pushMsg(`Renamed ${old} → ${pet.name}!`);
488
+ save();
489
+ }
490
+
491
+ // ── stat decay (called each tick) ─────────────────────────────────────────
492
+
493
+ function decayStats(dt) {
494
+ tickElapsed += dt;
495
+ if (tickElapsed < 2000) return; // decay every 2 real seconds
496
+ const secs = tickElapsed / 1000;
497
+ tickElapsed = 0;
498
+
499
+ if (state === 'sleeping') {
500
+ pet.energy = clamp(pet.energy + secs * 3, 0, 100);
501
+ pet.hunger = clamp(pet.hunger - secs * 0.2, 0, 100);
502
+ return;
503
+ }
504
+
505
+ pet.hunger = clamp(pet.hunger - secs * 1.2, 0, 100);
506
+ pet.happy = clamp(pet.happy - secs * 0.8, 0, 100);
507
+ pet.energy = clamp(pet.energy - secs * 0.5, 0, 100);
508
+ pet.clean = clamp(pet.clean - secs * 0.4, 0, 100);
509
+ pet.age += secs / 60;
510
+
511
+ // health consequences
512
+ if (pet.hunger < 15) pet.health = clamp(pet.health - secs * 1.5, 0, 100);
513
+ if (pet.clean < 20) pet.health = clamp(pet.health - secs * 0.8, 0, 100);
514
+ if (pet.energy < 5) pet.health = clamp(pet.health - secs * 0.5, 0, 100);
515
+
516
+ // passive health recovery when all stats are decent
517
+ if (pet.hunger > 50 && pet.clean > 50 && pet.energy > 40) {
518
+ pet.health = clamp(pet.health + secs * 0.3, 0, 100);
519
+ }
520
+
521
+ // spontaneous mood messages
522
+ if (rng(120) === 0) {
523
+ const moods = [
524
+ pet.hunger < 30 && `${pet.name} is hungry... 🍽️`,
525
+ pet.happy < 30 && `${pet.name} looks bored. Play with them!`,
526
+ pet.clean < 30 && `${pet.name} is getting smelly... 🦨`,
527
+ pet.energy < 20 && `${pet.name} is exhausted! Let them sleep.`,
528
+ pet.health < 40 && `${pet.name} doesn't look well... 💊`,
529
+ pet.happy > 80 && `${pet.name} is doing a happy wiggle! 🎉`,
530
+ ].filter(Boolean);
531
+ if (moods.length) pushMsg(moods[rng(moods.length)], 3000);
532
+ }
533
+
534
+ // death check
535
+ if (pet.health <= 0) {
536
+ pet.alive = false;
537
+ state = 'dead';
538
+ pushMsg(`💀 ${pet.name} has passed away... Press ENTER to hatch a new egg.`, 99999);
539
+ em.emit('death', pet);
540
+ save();
541
+ }
542
+
543
+ // score accumulates while pet is happy/healthy
544
+ const wellBeing = (pet.hunger + pet.happy + pet.energy + pet.clean + pet.health) / 5;
545
+ score += (wellBeing / 100) | 0;
546
+ em.emit('score', score);
547
+ save();
548
+ }
549
+
550
+ // ── input ──────────────────────────────────────────────────────────────────
551
+
552
+ function input(key) {
553
+ if (state === 'dead') {
554
+ if (key === 'enter' || key === 'space') {
555
+ pet = newPetData();
556
+ pushMsg(`A new ${pet.species} has hatched! 🥚`, 3000);
557
+ state = 'idle'; save();
558
+ }
559
+ return;
560
+ }
561
+
562
+ // name entry mode
563
+ if (subMenu?.type === 'name') {
564
+ if (key === 'escape') { subMenu = null; inputBuf = ''; return; }
565
+ if (key === 'enter') { rename(inputBuf); subMenu = null; inputBuf = ''; return; }
566
+ if (key === 'backspace') { inputBuf = inputBuf.slice(0,-1); return; }
567
+ if (key.length === 1 && inputBuf.length < 12) { inputBuf += key; return; }
568
+ return;
569
+ }
570
+
571
+ // sub-menu navigation (food / play)
572
+ if (subMenu) {
573
+ const list = subMenu.type === 'food' ? FOODS : GAMES_LIST;
574
+ if (key === 'escape') { subMenu = null; return; }
575
+ if (key === 'up') { subMenu.idx = (subMenu.idx - 1 + list.length) % list.length; return; }
576
+ if (key === 'down') { subMenu.idx = (subMenu.idx + 1) % list.length; return; }
577
+ if (key === 'enter' || key === 'space') {
578
+ if (subMenu.type === 'food') feed(subMenu.idx);
579
+ else play(subMenu.idx);
580
+ subMenu = null;
581
+ }
582
+ return;
583
+ }
584
+
585
+ // main controls
586
+ if (key === 'f') { subMenu = { type:'food', idx:0 }; return; }
587
+ if (key === 'p') { subMenu = { type:'play', idx:0 }; return; }
588
+ if (key === 's') { sleep(); return; }
589
+ if (key === 'b') { bathe(); return; }
590
+ if (key === 'm') { medicine(); return; }
591
+ if (key === 'n') { subMenu = { type:'name' }; inputBuf = ''; return; }
592
+ if (key === 'enter'||key==='space') {
593
+ // generic interact — gives a random small happy boost
594
+ pet.happy = clamp(pet.happy + 5, 0, 100);
595
+ const pats = [`${pet.name} purrs!`, `${pet.name} wags their tail!`,
596
+ `${pet.name} nuzzles you!`, `*pat pat*`];
597
+ pushMsg(pats[rng(pats.length)]);
598
+ }
599
+ }
600
+
601
+ // ── tick ───────────────────────────────────────────────────────────────────
602
+
603
+ function tick(dt = 16) {
604
+ frameCount++;
605
+ animTimer -= dt;
606
+ if (animTimer <= 0) {
607
+ animTimer = 500;
608
+ const spr = SPRITES[currentSprite()];
609
+ animFrame = (animFrame + 1) % spr.length;
610
+ }
611
+
612
+ // decay messages
613
+ for (const m of msgQueue) m.ttl -= dt;
614
+ while (msgQueue.length && msgQueue[msgQueue.length-1].ttl <= 0) msgQueue.pop();
615
+
616
+ if (pet.alive) decayStats(dt);
617
+ }
618
+
619
+ // ── frame ──────────────────────────────────────────────────────────────────
620
+
621
+ function statBar(label, val, len=12) {
622
+ const pct = val / 100;
623
+ const col = pct > 0.6 ? FC.GRN : pct > 0.3 ? FC.YEL : FC.RED;
624
+ return `${label} \x1b[${col}m${bar(val,100,len)}\x1b[0m ${Math.round(val)}%`;
625
+ }
626
+
627
+ function frame() {
628
+ const W=62, H=28;
629
+ const cv = makeCanvas(W, H);
630
+ cv.clear(' ', FC.wht, BC.blk);
631
+
632
+ // ── header ──
633
+ const age = pet.age < 60
634
+ ? `${pet.age.toFixed(0)}m`
635
+ : `${(pet.age/60).toFixed(1)}h`;
636
+ cv.put(0, 0, ` 🐾 KITPET ${pet.name} the ${pet.species} Lv.${pet.level} Age:${age} Score:${score}`, FC.YEL, BC.blk);
637
+ cv.put(0, 1, '─'.repeat(W), FC.BLK, BC.blk);
638
+
639
+ // ── sprite ──
640
+ const sprKey = currentSprite();
641
+ const spr = SPRITES[sprKey];
642
+ const frame_ = spr[animFrame % spr.length];
643
+ const sprCol = {
644
+ happy:FC.YEL, idle:FC.CYN, eating:FC.GRN, playing:FC.MAG,
645
+ sleeping:FC.BLU, sick:FC.RED, dirty:FC.yel, dead:FC.BLK,
646
+ bathing:FC.CYN, egg:FC.WHT,
647
+ }[sprKey] ?? FC.wht;
648
+
649
+ for (let i=0; i<frame_.length; i++) {
650
+ cv.put(2, i+3, frame_[i], sprCol, BC.blk);
651
+ }
652
+
653
+ // ── stats panel ──
654
+ const sx = 16;
655
+ cv.put(sx, 2, bold('── STATS ─────────────────'), FC.wht, BC.blk);
656
+ cv.put(sx, 3, statBar('🍔 Hunger', pet.hunger), FC.wht, BC.blk);
657
+ cv.put(sx, 4, statBar('😊 Happy ', pet.happy), FC.wht, BC.blk);
658
+ cv.put(sx, 5, statBar('⚡ Energy', pet.energy), FC.wht, BC.blk);
659
+ cv.put(sx, 6, statBar('🧼 Clean ', pet.clean), FC.wht, BC.blk);
660
+ cv.put(sx, 7, statBar('❤️ Health', pet.health), FC.wht, BC.blk);
661
+
662
+ // xp bar
663
+ const xpPct = pet.xp / pet.xpNext;
664
+ cv.put(sx, 8, `XP ${bar(pet.xp, pet.xpNext, 20, '▰','▱')} ${pet.xp}/${pet.xpNext}`, FC.MAG, BC.blk);
665
+
666
+ // ── action menu ──
667
+ cv.put(sx, 10, bold('── ACTIONS ───────────────'), FC.wht, BC.blk);
668
+
669
+ if (subMenu?.type === 'name') {
670
+ cv.put(sx, 11, 'Enter new name:', FC.CYN, BC.blk);
671
+ cv.put(sx, 12, `> ${inputBuf}▌`, FC.WHT, BC.blk);
672
+ cv.put(sx, 13, 'ENTER confirm ESC cancel', FC.BLK, BC.blk);
673
+ } else if (subMenu) {
674
+ const list = subMenu.type === 'food' ? FOODS : GAMES_LIST;
675
+ cv.put(sx, 11, subMenu.type === 'food' ? 'Choose food:' : 'Choose game:', FC.CYN, BC.blk);
676
+ for (let i=0; i<list.length; i++) {
677
+ const item = list[i];
678
+ const sel = i === subMenu.idx;
679
+ const icon = item.icon ?? '•';
680
+ const label = `${sel?'▶':' '} ${icon} ${item.name}`;
681
+ cv.put(sx, 12+i, label, sel ? FC.YEL : FC.BLK, BC.blk);
682
+ }
683
+ cv.put(sx, 12+list.length+1, '↑↓ select ENTER confirm ESC back', FC.BLK, BC.blk);
684
+ } else {
685
+ const actions = [
686
+ '[F] Feed', '[P] Play', '[S] Sleep/Wake',
687
+ '[B] Bathe', '[M] Medicine', '[N] Rename',
688
+ '[SPACE] Pat',
689
+ ];
690
+ for (let i=0; i<actions.length; i++) {
691
+ cv.put(sx + (i%2)*22, 11 + (i>>1), actions[i], FC.wht, BC.blk);
692
+ }
693
+ }
694
+
695
+ // ── status / mood ──
696
+ cv.put(0, 16, '─'.repeat(W), FC.BLK, BC.blk);
697
+
698
+ // mood emoji line
699
+ const mood =
700
+ !pet.alive ? '💀 Passed away...' :
701
+ state==='sleeping' ? '💤 Sleeping...' :
702
+ state==='eating' ? '😋 Nom nom nom...' :
703
+ state==='playing' ? '🎮 Playing!' :
704
+ state==='bathing' ? '🛁 Splish splash!' :
705
+ pet.health < 30 ? '🤒 Not feeling well...' :
706
+ pet.clean < 30 ? '🦨 Could use a bath...' :
707
+ pet.hunger < 25 ? '😰 Very hungry!' :
708
+ pet.happy < 25 ? '😔 Feeling lonely...' :
709
+ pet.energy < 20 ? '😴 So sleepy...' :
710
+ pet.happy > 80 ? '😄 Feeling great!' :
711
+ '😌 Chilling.';
712
+ cv.put(1, 17, mood, FC.WHT, BC.blk);
713
+
714
+ // ── message log ──
715
+ cv.put(0, 18, '─'.repeat(W), FC.BLK, BC.blk);
716
+ for (let i=0; i<Math.min(msgQueue.length, 5); i++) {
717
+ const m = msgQueue[i];
718
+ const alpha = m.ttl > 1000 ? FC.WHT : FC.BLK;
719
+ cv.put(1, 19+i, m.text.slice(0,W-2), alpha, BC.blk);
720
+ }
721
+
722
+ // ── footer ──
723
+ cv.put(0, H-1, '─'.repeat(W), FC.BLK, BC.blk);
724
+ if (state === 'dead') {
725
+ cv.put(1, H-1, ' ENTER to hatch a new egg ', FC.WHT, BC.red);
726
+ }
727
+
728
+ return cv.render();
729
+ }
730
+
731
+ return {
732
+ start, input, tick, frame, save,
733
+ on: em.on.bind(em),
734
+ off: em.off.bind(em),
735
+ get state() { return state; },
736
+ get score() { return score; },
737
+ get pet() { return pet; },
738
+ };
739
+ }
740
+
741
+ // ─── EXPORT ──────────────────────────────────────────────────────────────────
742
+
743
+ const kitdef = {
744
+ Pet: (opts) => {
745
+ const g = makePet(opts);
746
+ g.start();
747
+ return g;
748
+ },
749
+ makePet,
750
+ };
751
+
752
+ module.exports = { kitdef };