lilgotchi 0.0.0-stage → 3.0.2

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.
Files changed (120) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +69 -3
  3. package/dist/LICENSE.md +21 -0
  4. package/dist/README.md +69 -0
  5. package/dist/design/evolution/README.md +7 -0
  6. package/dist/design/evolution/cypunk.js +3 -0
  7. package/dist/design/evolution/cypunk.md +20 -0
  8. package/dist/design/evolution/index.html +20 -0
  9. package/dist/design/evolution/lilgotchi.js +3 -0
  10. package/dist/design/evolution/lilgotchi.md +20 -0
  11. package/dist/design/evolution/preview.css +3 -0
  12. package/dist/design/evolution/preview.js +47 -0
  13. package/dist/design/evolution/slice.js +3 -0
  14. package/dist/design/evolution/slice.md +14 -0
  15. package/dist/design/games/README.md +52 -0
  16. package/dist/design/games/bump.md +23 -0
  17. package/dist/design/games/catalog.md +24 -0
  18. package/dist/design/games/flag.md +19 -0
  19. package/dist/design/games/get.md +21 -0
  20. package/dist/design/games/heading.md +23 -0
  21. package/dist/design/games/hoops.md +17 -0
  22. package/dist/design/games/left-right.md +30 -0
  23. package/dist/design/games/memory.md +21 -0
  24. package/dist/design/games/polish-review.md +11 -0
  25. package/dist/design/games/release-checklist.md +52 -0
  26. package/dist/design/games/sprint.md +21 -0
  27. package/dist/docs/README.md +14 -0
  28. package/dist/docs/care.md +51 -0
  29. package/dist/docs/characters.md +51 -0
  30. package/dist/docs/credits.md +17 -0
  31. package/dist/docs/development.md +87 -0
  32. package/dist/docs/frame-contract.md +38 -0
  33. package/dist/docs/images/cypunk-family.jpg +0 -0
  34. package/dist/docs/images/lilgotchi-family.jpg +0 -0
  35. package/dist/docs/images/original-family.jpg +0 -0
  36. package/dist/docs/images/slice-family.jpg +0 -0
  37. package/dist/docs/install.md +41 -0
  38. package/dist/docs/release-review.md +47 -0
  39. package/dist/embed.js +21 -0
  40. package/dist/examples/cross-origin.html +7 -0
  41. package/dist/examples/curated.html +12 -0
  42. package/dist/examples/duplicate.html +3 -0
  43. package/dist/examples/install.html +11 -0
  44. package/dist/examples/launcher-review.html +23 -0
  45. package/dist/examples/launchers.html +10 -0
  46. package/dist/examples/nested/index.html +6 -0
  47. package/dist/examples/one-line.html +9 -0
  48. package/dist/index.html +100 -0
  49. package/dist/lilgotchi.js +437 -0
  50. package/dist/pets/NOTICE.md +19 -0
  51. package/dist/pets/README.md +7 -0
  52. package/dist/pets/arcade-catalog.js +52 -0
  53. package/dist/pets/arcade-geometry.js +18 -0
  54. package/dist/pets/arcade-opponents.js +16 -0
  55. package/dist/pets/arcade-renderer.js +116 -0
  56. package/dist/pets/arcade-sprites.js +64 -0
  57. package/dist/pets/arcade.js +393 -0
  58. package/dist/pets/away.js +20 -0
  59. package/dist/pets/boot.js +161 -0
  60. package/dist/pets/catalog.js +32 -0
  61. package/dist/pets/celebrations.js +54 -0
  62. package/dist/pets/classic-sprites.js +672 -0
  63. package/dist/pets/clock.js +3 -0
  64. package/dist/pets/colors.js +38 -0
  65. package/dist/pets/constants.js +20 -0
  66. package/dist/pets/custom-themes.css +80 -0
  67. package/dist/pets/cyberpunk-sprites.js +68 -0
  68. package/dist/pets/cypunk-original.js +2 -0
  69. package/dist/pets/embed.css +130 -0
  70. package/dist/pets/engine.js +38 -0
  71. package/dist/pets/evolution.js +46 -0
  72. package/dist/pets/focus.js +27 -0
  73. package/dist/pets/font.js +185 -0
  74. package/dist/pets/game.css +275 -0
  75. package/dist/pets/game.html +148 -0
  76. package/dist/pets/game.js +626 -0
  77. package/dist/pets/layout.js +53 -0
  78. package/dist/pets/lilgotchi-sprites.js +62 -0
  79. package/dist/pets/minigames/bump.js +52 -0
  80. package/dist/pets/minigames/flag.js +87 -0
  81. package/dist/pets/minigames/get.js +59 -0
  82. package/dist/pets/minigames/heading.js +117 -0
  83. package/dist/pets/minigames/hoops.js +96 -0
  84. package/dist/pets/minigames/memory.js +81 -0
  85. package/dist/pets/minigames/model.js +40 -0
  86. package/dist/pets/minigames/sprint.js +103 -0
  87. package/dist/pets/original-roster.js +46 -0
  88. package/dist/pets/original-sprites.js +3861 -0
  89. package/dist/pets/overlay-size.js +13 -0
  90. package/dist/pets/pixel-size.js +6 -0
  91. package/dist/pets/presentation.js +80 -0
  92. package/dist/pets/renderer.js +100 -0
  93. package/dist/pets/scenery.css +24 -0
  94. package/dist/pets/simulation.js +185 -0
  95. package/dist/pets/skins.js +14 -0
  96. package/dist/pets/slice-sprites.js +38 -0
  97. package/dist/pets/sound.js +33 -0
  98. package/dist/pets/status.js +28 -0
  99. package/dist/pets/storage.js +49 -0
  100. package/dist/pets/valid-state.js +26 -0
  101. package/dist/pets/wellbeing.js +73 -0
  102. package/dist/reference/source.json +8 -0
  103. package/dist/version.json +5 -0
  104. package/dist/widget/care-guide.js +50 -0
  105. package/dist/widget/care-monitor.js +52 -0
  106. package/dist/widget/color-controls.js +15 -0
  107. package/dist/widget/configurator.js +24 -0
  108. package/dist/widget/egg.svg +1 -0
  109. package/dist/widget/focus.js +6 -0
  110. package/dist/widget/gallery.js +17 -0
  111. package/dist/widget/inactive.js +24 -0
  112. package/dist/widget/install-snippet.js +14 -0
  113. package/dist/widget/launcher.js +63 -0
  114. package/dist/widget/pet-history.js +57 -0
  115. package/dist/widget/pet-label.js +12 -0
  116. package/dist/widget/save-store.js +198 -0
  117. package/dist/widget/scroll-lock.js +11 -0
  118. package/dist/widget/settings.js +48 -0
  119. package/dist/widget/shell.css +63 -0
  120. package/package.json +43 -6
@@ -0,0 +1,626 @@
1
+ import { Arcade } from './arcade.js';
2
+ import { now as clockNow, setSimulationTime } from './clock.js';
3
+ // Adapted from plateaukao/tamagochi at 74c7a45. See NOTICE.md.
4
+ import { C } from './constants.js';
5
+ import { initializeWellbeing, advanceWellbeing, applyWellbeingAction, setManualSleep, sleepDecision, careGrade } from './wellbeing.js';
6
+ import { updatePresentation, careAction, motionOffset, tiltFrame } from './presentation.js';
7
+ import { GrowthCelebration } from './celebrations.js';
8
+ import { overlayControls } from './focus.js';
9
+ import { limitAbsence, resumeVacation } from './away.js';
10
+ import { GameState, Evo } from './engine.js';
11
+ import { SoundManager } from './sound.js';
12
+ import { Renderer } from './renderer.js';
13
+ import { SP, characterName } from './skins.js';
14
+ import { updateStatus } from './status.js';
15
+ import { drawScene, centeredBody } from './layout.js';
16
+ import { PF } from './font.js';
17
+ import { petType, selectedCharacter } from './storage.js';
18
+
19
+ import { PetSimulation } from './simulation.js';
20
+
21
+ export class TamagotchiApp extends PetSimulation {
22
+ constructor(options={}){
23
+ super();
24
+ this.evolutionName=characterName;
25
+ this.renderer=new Renderer(document.getElementById('lcd'));
26
+ this.sound=new SoundManager();
27
+ this.state=new GameState();
28
+ this.frame=0;
29
+ this.presentationEnabled=true;
30
+ this.panelVisible=true;
31
+ this.growth=new GrowthCelebration(document.getElementById('growth-notice'));
32
+ this.animState=null;
33
+ this.animTimer=0;
34
+ this.gameActive=false;
35
+ this.gameRound=0;
36
+ this.gameWins=0;
37
+ this.gameDir=0;
38
+ this.gamePhase='idle';
39
+ this.toastTimer=null;
40
+ this.eink=options.eink===true;
41
+ this.onThemeChange=options.onThemeChange||(()=>{});
42
+ this.scanlines=options.scanlines===true;
43
+ this.onScanlinesChange=options.onScanlinesChange||(()=>{});
44
+ this.init();
45
+ this.arcade=new Arcade(this);
46
+ }
47
+
48
+ applyTheme(){
49
+ document.documentElement.classList.toggle('eink',this.eink);
50
+ const toggle=document.getElementById('eink-toggle');
51
+ toggle.classList.toggle('on',this.eink);
52
+ toggle.setAttribute('aria-checked',String(this.eink));
53
+ this.renderer.updateColors();
54
+ }
55
+
56
+ init(){
57
+ this.applyTheme();
58
+ this.loadedSave=this.state.load();
59
+ if(this.loadedSave){
60
+ this.processMissedTime();
61
+ } else {
62
+ this.state.reset();
63
+ }
64
+ initializeWellbeing(this.state,clockNow());
65
+ document.getElementById('vacation-toggle')?.addEventListener('click',()=>this.toggleVacation());
66
+ const scanlineToggle=document.getElementById('scanline-toggle');
67
+ document.documentElement.classList.toggle('scanlines',this.scanlines);
68
+ scanlineToggle?.setAttribute('aria-checked',String(this.scanlines));
69
+ scanlineToggle?.classList.toggle('on',this.scanlines);
70
+ scanlineToggle?.addEventListener('click',()=>{
71
+ const enabled=document.documentElement.classList.toggle('scanlines');
72
+ scanlineToggle.setAttribute('aria-checked',String(enabled));
73
+ scanlineToggle.classList.toggle('on',enabled);
74
+ this.onScanlinesChange(enabled);
75
+ });
76
+ // Init audio on first interaction
77
+ const initAudio=()=>{ this.sound.init(); };
78
+ document.addEventListener('touchstart',initAudio,{once:true});
79
+ document.addEventListener('click',initAudio,{once:true});
80
+
81
+ // Control button handlers
82
+ document.querySelectorAll('.ctrl-btn').forEach(btn=>{
83
+ btn.addEventListener('click',e=>{
84
+ e.preventDefault();
85
+ this.sound.init(); // ensure audio context
86
+ this.sound.btnPress();
87
+ this.handleAction(btn.dataset.action); this.state.save();
88
+ });
89
+ });
90
+
91
+ // Feed overlay buttons
92
+ document.getElementById('feed-meal-btn').addEventListener('click',e=>{
93
+ e.preventDefault(); this.doFeed('meal'); this.state.save(); this.closeOverlay('feed-overlay');
94
+ });
95
+ document.getElementById('feed-snack-btn').addEventListener('click',e=>{
96
+ e.preventDefault(); this.doFeed('snack'); this.state.save(); this.closeOverlay('feed-overlay');
97
+ });
98
+
99
+ // Close overlays
100
+ document.querySelectorAll('[data-close]').forEach(el=>{
101
+ el.addEventListener('click',e=>{ e.preventDefault(); this.closeOverlay(el.dataset.close); });
102
+ });
103
+
104
+ // Game arrows
105
+ document.getElementById('guess-left').addEventListener('click',()=>this.gameGuess(0));
106
+ document.getElementById('guess-right').addEventListener('click',()=>this.gameGuess(1));
107
+ document.getElementById('guess-replay').addEventListener('click',()=>this.startGame());
108
+ document.getElementById('guess-exit').addEventListener('click',()=>{this.closeOverlay('game-overlay');this.arcade.open();});
109
+
110
+ document.getElementById('settings-btn').addEventListener('click',()=>this.openOverlay('settings-overlay'));
111
+ document.getElementById('eink-toggle').addEventListener('click',()=>{
112
+ this.eink=!this.eink; this.applyTheme(); this.onThemeChange(this.eink);
113
+ this.toast(this.eink?'E-ink theme on':'Default theme');
114
+ });
115
+ document.getElementById('reset-game-btn').addEventListener('click',()=>{
116
+ if(confirm(`Restart ${selectedCharacter.name}? This cannot be undone.`)){
117
+ this.growth?.clear();this.landingAt=null;
118
+ this.state.clearSave(); this.state.reset(); this.animState=null; this.state.save();
119
+ this.closeOverlay('settings-overlay'); this.closeOverlay('death-overlay');
120
+ this.toast(`${selectedCharacter.name} started again.`);
121
+ }
122
+ });
123
+ document.getElementById('restart-btn').addEventListener('click',()=>{
124
+ this.growth?.clear();this.landingAt=null;
125
+ this.state.clearSave(); this.state.reset();
126
+ this.animState=null; this.state.save(); this.updateButtons();
127
+ this.closeOverlay('death-overlay');
128
+ this.toast(`${selectedCharacter.name} started again.`);
129
+ });
130
+
131
+ // Visibility change
132
+ document.addEventListener('visibilitychange',()=>{
133
+ if(document.hidden) { this.arcade?.pause(); this.interruptGuess(); this.state.save(); }
134
+ else { this.processMissedTime(); this.updateButtons(); }
135
+ });
136
+
137
+ // Start loops
138
+ setInterval(()=>this.tick(),1000);
139
+ const renderLoop=()=>{ if(!document.hidden&&this.panelVisible!==false) this.render(); requestAnimationFrame(renderLoop); };
140
+ requestAnimationFrame(renderLoop);
141
+ setInterval(()=>this.state.save(),30000);
142
+ setInterval(()=>this.maybeMisbehave(),600000);
143
+
144
+
145
+ // Update button states immediately and periodically
146
+ this.updateButtons();
147
+ setInterval(()=>this.updateButtons(),2000);
148
+
149
+ if(this.state.isDead) this.showDeath();
150
+ this.state.save();
151
+ }
152
+
153
+ // -- TOAST NOTIFICATIONS --
154
+ toast(msg){
155
+ if(this.catchingUp||this.panelVisible===false) return;
156
+ const el=document.getElementById('toast');
157
+ el.textContent=msg;
158
+ el.classList.add('show');
159
+ clearTimeout(this.toastTimer);
160
+ this.toastTimer=setTimeout(()=>el.classList.remove('show'),2000);
161
+ }
162
+
163
+ // -- BUTTON STATE UPDATES --
164
+ updateButtons(){
165
+ if(this.catchingUp) return;
166
+ const st=this.state;
167
+ const vacation=document.getElementById('vacation-toggle');
168
+ const attention=document.getElementById('attention-bar');
169
+ if(attention?.style)attention.style.animationName=this.motionDisabled()?'none':'';
170
+ vacation?.setAttribute?.('aria-checked',String(st.vacationAt>0));
171
+ vacation?.classList.toggle('on',st.vacationAt>0);
172
+ updateStatus(st);
173
+ updatePresentation(st,clockNow(),this.renderer);
174
+ if(document.getElementById('stats-overlay')?.classList.contains?.('show')) this.renderStats();
175
+ const btns=document.querySelectorAll('.ctrl-btn');
176
+ btns.forEach(btn=>{
177
+ const action=btn.dataset.action;
178
+ let disabled=false;
179
+ if(st.vacationAt>0&&!['mute','stats'].includes(action)) disabled=true;
180
+ else if(st.isDead&&action!=='mute') disabled=true;
181
+ else switch(action){
182
+ case 'feed': disabled=st.stage==='egg'||st.isSleeping; break;
183
+ case 'light': disabled=st.stage==='egg'; break;
184
+ case 'play': disabled=st.stage==='egg'||st.isSleeping||st.isSick; break;
185
+ case 'medicine': disabled=!st.isSick; break;
186
+ case 'clean': disabled=st.poopCount===0; break;
187
+ case 'discipline': disabled=!st.isMisbehaving; break;
188
+ case 'stats': disabled=false; break;
189
+ case 'care': disabled=st.stage==='egg'; break;
190
+ }
191
+ btn.classList.toggle('disabled',disabled);
192
+ btn.disabled=disabled;
193
+ btn.title=disabled?(st.vacationAt>0?'Resume Vacation in Settings':st.stage==='egg'?'Available after hatching':st.isSleeping?'Wake your pet first':action==='play'&&st.isSick?'Needs medicine before playing':action==='medicine'?'Medicine is only needed when sick':action==='discipline'?'No misbehavior to correct':action==='clean'?'Already clean':'Unavailable right now'):'';
194
+ });
195
+ }
196
+
197
+ // -- TIME CATCH-UP --
198
+ toggleVacation(){
199
+ const st=this.state, now=clockNow();
200
+ if(st.vacationAt>0){
201
+ resumeVacation(st,now);
202
+ this.toast('Vacation ended. Your pet is ready.');
203
+ } else {
204
+ if(st.isDead){this.toast('Start again to care for a new pet.');return;}
205
+ this.processMissedTime();
206
+ if(st.isDead)return;
207
+ st.vacationAt=now;
208
+ this.arcade?.cancel();this.closeOverlay('arcade-overlay');
209
+ this.gameActive=false;this.gameId=(this.gameId||0)+1;this.animState=null;
210
+ this.closeOverlay('game-overlay');this.closeOverlay('feed-overlay');
211
+ this.toast('Vacation on. Care and growth are paused.');
212
+ }
213
+ st.save();this.updateButtons();
214
+ }
215
+
216
+ // -- ACTIONS --
217
+ handleAction(action){
218
+ const st=this.state;
219
+
220
+ // Mute always works
221
+ if(action==='mute'){
222
+ this.sound.toggleMute();
223
+ const mb=document.getElementById('mute-btn');
224
+ mb.querySelector('.icon-color').innerHTML=this.sound.muted?'🔇':'🔈';
225
+ const svg=document.getElementById('mute-icon-mono');
226
+ svg.innerHTML=this.sound.muted
227
+ ?'<polygon points="6,9 2,9 2,15 6,15 11,19 11,5" fill="currentColor" stroke="none"/><line x1="16" y1="8" x2="22" y2="16"/><line x1="22" y1="8" x2="16" y2="16"/>'
228
+ :'<polygon points="6,9 2,9 2,15 6,15 11,19 11,5" fill="currentColor" stroke="none"/><path d="M15 9a4 4 0 0 1 0 6"/><path d="M18 7a7 7 0 0 1 0 10"/>';
229
+ mb.querySelector('.label').textContent=this.sound.muted?'Muted':'Sound';
230
+ this.toast(this.sound.muted?'Sound off':'Sound on');
231
+ return;
232
+ }
233
+
234
+ if(st.isDead){ this.toast('Your pet has passed away...'); return; }
235
+
236
+ if(st.vacationAt>0&&action!=='stats'){this.toast('Vacation is on. Resume in Settings.');return;}
237
+ if(!st.vacationAt)advanceWellbeing(st,clockNow());
238
+ switch(action){
239
+ case 'care': {
240
+ if(st.stage==='egg')return;
241
+ const next=careAction(st);
242
+ if(next==='clean'){this.handleAction('clean');return;}
243
+ setManualSleep(st,next==='sleep',clockNow());
244
+ this.toast(next==='sleep'?'Settling down for a nap.':'Awake and ready for company.');
245
+ this.updateButtons();
246
+ break;
247
+ }
248
+ case 'feed':
249
+ if(st.stage==='egg'){ this.toast('Egg is hatching... wait!'); this.sound.denied(); return; }
250
+ if(st.isSleeping){ this.toast('Shh... sleeping!'); this.sound.denied(); return; }
251
+ this.openOverlay('feed-overlay');
252
+ break;
253
+
254
+ case 'light':
255
+ if(st.stage==='egg'){ this.toast('Egg is hatching... wait!'); this.sound.denied(); return; }
256
+ st.lightOn=!st.lightOn;
257
+ this.toast(st.lightOn?'Light on':'Light off');
258
+ break;
259
+
260
+ case 'play':
261
+ if(st.stage==='egg'){ this.toast('Egg is hatching... wait!'); this.sound.denied(); return; }
262
+ if(st.isSleeping){ this.toast('Shh... sleeping!'); this.sound.denied(); return; }
263
+ if(st.isSick){ this.toast('Too sick to play!'); this.sound.denied(); return; }
264
+ if(st.hunger===0){ this.toast('Too hungry to play! Feed first.'); this.sound.denied(); return; }
265
+ this.arcade.open();
266
+ break;
267
+
268
+ case 'medicine':
269
+ if(!st.isSick){ this.toast('Not sick!'); this.sound.denied(); return; }
270
+ st.medDoses--;
271
+ this.sound.medicine();
272
+ this.animState='medicine'; this.animTimer=clockNow();
273
+ if(st.medDoses<=0){
274
+ st.isSick=false; st.medDoses=0;
275
+ applyWellbeingAction(st,'cured',clockNow());
276
+ st.attentionCallTime=null; st.attentionReason=null;
277
+ this.toast('Cured!');
278
+ } else {
279
+ this.toast('Medicine given... needs more');
280
+ }
281
+ this.updateButtons();
282
+ break;
283
+
284
+ case 'clean':
285
+ if(st.poopCount===0){ this.toast('Already clean!'); this.sound.denied(); return; }
286
+ st.poopCount=0;
287
+ this.sound.clean();
288
+ this.animState='clean'; this.animTimer=clockNow();
289
+ this.toast('Cleaned up!');
290
+ this.updateButtons();
291
+ break;
292
+
293
+ case 'stats':
294
+ if(st.stage==='egg'){
295
+ const pct=Math.min(100,Math.floor((clockNow()-st.stageStartTime)/C.EGG_MS*100));
296
+ this.toast(`Hatching: ${pct}%`);
297
+ return;
298
+ }
299
+ this.showStats();
300
+ break;
301
+
302
+ case 'discipline':
303
+ if(!st.isMisbehaving){ this.toast('Not misbehaving!'); this.sound.denied(); return; }
304
+ st.discipline=Math.min(st.discipline+C.DISC_INC,C.MAX_DISC);
305
+ st.isMisbehaving=false;
306
+ st.attentionCallTime=null; st.attentionReason=null;
307
+ this.sound.discipline();
308
+ this.animState='discipline'; this.animTimer=clockNow();
309
+ this.toast(`Discipline: ${st.discipline}%`);
310
+ this.updateButtons();
311
+ break;
312
+ }
313
+ }
314
+
315
+ doFeed(type){
316
+ const st=this.state;
317
+ if(st.vacationAt>0)return;
318
+ if(st.isSleeping||st.isDead) return;
319
+ advanceWellbeing(st,clockNow());
320
+ if(type==='meal'){
321
+ if(st.hunger>=C.MAX_HUNGER){ this.toast('Not hungry!'); this.sound.denied(); return; }
322
+ st.hunger=Math.min(st.hunger+1,C.MAX_HUNGER);
323
+ st.weight=Math.min(st.weight+C.MEAL_WT,C.MAX_WEIGHT);
324
+ applyWellbeingAction(st,'meal',clockNow());
325
+ this.toast('Yum! Food +1');
326
+ } else {
327
+ if(st.happiness>=C.MAX_HAPPY){ this.toast('Already happy!'); this.sound.denied(); return; }
328
+ st.happiness=Math.min(st.happiness+1,C.MAX_HAPPY);
329
+ st.weight=Math.min(st.weight+C.SNACK_WT,C.MAX_WEIGHT);
330
+ const now=clockNow();
331
+ if(now-st.snackWindowStart>600000){ st.snacksInWindow=0; st.snackWindowStart=now; }
332
+ st.snacksInWindow++;
333
+ if(st.snacksInWindow>=C.SNACK_SICK) this.makeSick();
334
+ this.toast('Yum! Happy +1');
335
+ }
336
+ this.sound.eat();
337
+ this.animState='eat'; this.animTimer=clockNow();
338
+ if(st.hunger>0&&st.happiness>0&&!st.isMisbehaving&&!st.isSick){
339
+ st.attentionCallTime=null; st.attentionReason=null;
340
+ }
341
+ this.updateButtons();
342
+ }
343
+
344
+ showStats(){
345
+ this.renderStats();
346
+ this.openOverlay('stats-overlay');
347
+ }
348
+
349
+ renderStats(){
350
+ const st=this.state;
351
+ const hF='\u2665', hE='\u2661';
352
+ const hungerH=hF.repeat(st.hunger)+hE.repeat(C.MAX_HUNGER-st.hunger);
353
+ const happyH=hF.repeat(st.happiness)+hE.repeat(C.MAX_HAPPY-st.happiness);
354
+ const name=characterName(st.character);
355
+ document.getElementById('stats-content').innerHTML=`
356
+ <div class="stat-row"><span>Name</span><span>${name}</span></div>
357
+ <div class="stat-row"><span>Stage</span><span>${st.stage[0].toUpperCase()+st.stage.slice(1)}</span></div>
358
+ <div class="stat-row"><span>Age</span><span>${st.age} days</span></div>
359
+ <div class="stat-row"><span>Weight</span><span>${st.weight} lb</span></div>
360
+ <div class="stat-row"><span>Food</span><span class="hearts">${hungerH} ${st.hunger}/4</span></div>
361
+ <div class="stat-row"><span>Happy</span><span class="hearts">${happyH} ${st.happiness}/4</span></div>
362
+ <div class="stat-row"><span>Energy</span><span>${Math.round(st.energy??100)}%</span></div>
363
+ <div class="stat-row"><span>Health</span><span>${Math.round(st.health??100)}%</span></div>
364
+ <div class="stat-row"><span>Care</span><span>${careGrade(st).label}</span></div>
365
+ <div class="stat-row"><span>Discipline</span><span>${st.discipline}%</span></div>
366
+ `;
367
+ }
368
+
369
+ // -- MINI-GAME --
370
+ startGame(){
371
+ const reason=this.arcade?.unavailable();
372
+ if(reason){document.getElementById('game-result').textContent=reason;document.getElementById('guess-replay').disabled=true;return;}
373
+ if(this.state.vacationAt>0||this.state.isDead)return;
374
+ document.getElementById('guess-actions').hidden=false;
375
+ document.getElementById('guess-replay').hidden=true;
376
+ document.getElementById('guess-replay').disabled=false;
377
+ applyWellbeingAction(this.state,'play',clockNow());
378
+ this.cancelGame();
379
+ this.gameCharacter=this.state.character;
380
+ this.gameActive=true; this.gameRound=0; this.gameWins=0;
381
+ this.gamePhase='waiting';
382
+ this.nextGameRound();
383
+ this.openOverlay('game-overlay');
384
+ }
385
+
386
+ nextGameRound(){
387
+ if(this.state.isDead||this.state.vacationAt>0)return;
388
+ if(this.gameRound>=5){ this.finishGame(); return; }
389
+ this.gameDir=Math.random()<.5?0:1;
390
+ this.gamePhase='waiting';
391
+ document.getElementById('game-score').textContent=`Round ${this.gameRound+1}/5 | Wins: ${this.gameWins}`;
392
+ document.getElementById('game-result').innerHTML='Tap an arrow!';
393
+ document.getElementById('guess-left').classList.remove('disabled');
394
+ document.getElementById('guess-right').classList.remove('disabled');
395
+ const sp=SP[this.state.character];
396
+ if(sp) this.renderer.renderToCanvas(document.getElementById('game-pet-canvas'),sp.idle1,8,{field:true});
397
+ }
398
+
399
+ gameGuess(dir){
400
+ if(this.state.vacationAt>0||this.state.isDead)return;
401
+ if(this.gamePhase!=='waiting'||!this.gameActive) return;
402
+ this.gamePhase='reveal';
403
+ document.getElementById('guess-left').classList.add('disabled');
404
+ document.getElementById('guess-right').classList.add('disabled');
405
+ const won=dir===this.gameDir;
406
+ if(won) this.gameWins++;
407
+ this.state.weight=Math.max(1,this.state.weight-C.GAME_WT_LOSS);
408
+ const dirText=this.gameDir===0?'LEFT':'RIGHT';
409
+ document.getElementById('game-result').textContent=`Pet went ${dirText}! ${won?'Correct!':'Wrong!'}`;
410
+ document.getElementById('game-score').textContent=`Round ${this.gameRound+1}/5 | Wins: ${this.gameWins}`;
411
+ const sp=SP[this.state.character];
412
+ if(sp){
413
+ const frame=this.gameDir===0?'lookLeft':'lookRight';
414
+ this.renderer.renderToCanvas(document.getElementById('game-pet-canvas'),sp[frame]||sp.idle1,8,{field:true});
415
+ }
416
+ this.sound[won?'happy':'sad']();
417
+ this.gameRound++;
418
+ const gameId=this.gameId;
419
+ const current=()=>this.gameActive&&this.gameId===gameId&&this.gameCharacter===this.state.character&&!this.state.isDead&&!this.state.vacationAt;
420
+ this.gameReactionTimer=setTimeout(()=>{
421
+ if(!current())return;
422
+ if(sp)this.renderer.renderToCanvas(document.getElementById('game-pet-canvas'),sp[won?'happy':'sad']||sp.idle1,8,{field:true});
423
+ },700);
424
+ this.gameRoundTimer=setTimeout(()=>{
425
+ if(!current()) return;
426
+ if(this.gameRound>=5) this.finishGame();
427
+ else this.nextGameRound();
428
+ },1200);
429
+ }
430
+
431
+ finishGame(){
432
+ if(!this.gameActive||this.state.vacationAt>0||this.state.isDead)return;
433
+ this.gameActive=false;
434
+ const won=this.gameWins>=3;
435
+ if(won){
436
+ this.state.happiness=Math.min(this.state.happiness+1,C.MAX_HAPPY);
437
+ this.sound.gameWin();
438
+ document.getElementById('game-result').textContent=`You won ${this.gameWins}/5! +1 Happy`;
439
+ } else {
440
+ this.sound.gameLose();
441
+ document.getElementById('game-result').textContent=`You lost ${this.gameWins}/5. Try again!`;
442
+ }
443
+ document.getElementById('game-score').textContent='Game Over!';
444
+ document.getElementById('guess-left').classList.add('disabled');
445
+ document.getElementById('guess-right').classList.add('disabled');
446
+ this.state.save();
447
+ document.getElementById('guess-actions').hidden=true;
448
+ document.getElementById('guess-replay').hidden=false;
449
+ document.getElementById('guess-replay').disabled=!!this.arcade?.unavailable();
450
+ document.getElementById('guess-replay').focus();
451
+ this.updateButtons();
452
+ }
453
+
454
+ interruptGuess(){
455
+ if(!this.gameActive)return;
456
+ this.cancelGame();
457
+ document.getElementById('guess-actions').hidden=true;
458
+ document.getElementById('guess-replay').hidden=false;
459
+ document.getElementById('game-result').textContent='Round stopped while you were away. Play again when ready.';
460
+ }
461
+
462
+ // -- RENDERING --
463
+ render(){
464
+ if(this.gameActive&&this.gameCharacter!==this.state.character){
465
+ this.interruptGuess();
466
+ document.getElementById('game-result').textContent='Your pet grew! Play again with your new look.';
467
+ }
468
+ const R=this.renderer, st=this.state;
469
+ R.clear();
470
+ const still=this.motionDisabled();
471
+ if(!still) this.frame++;
472
+
473
+ if(st.isDead){
474
+ const items=[{name:'grave',pixels:SP.grave,x:13,y:8}];
475
+ if(still||this.frame%90<45) items.push({name:'angel',pixels:SP.angel,x:13,y:1});
476
+ drawScene(R,items);
477
+ R.render(); return;
478
+ }
479
+
480
+ const now=st.vacationAt||clockNow();
481
+
482
+ // Animation override
483
+ if(this.animState&&now-this.animTimer<2000){
484
+ this.renderAnim(R,st);
485
+ R.render(!st.lightOn); return;
486
+ }
487
+ this.animState=null;
488
+
489
+ if(st.stage==='egg'){
490
+ const sp=this.frame%90<45?SP.egg.idle1:SP.egg.idle2;
491
+ drawScene(R,[{name:'egg',pixels:sp,x:Math.floor((32-sp[0].length)/2),y:2}],[{left:6,top:12,right:25,bottom:14}]);
492
+ // Show hatching progress bar
493
+ const frac=(now-st.stageStartTime)/C.EGG_MS;
494
+ R.drawBar(6,12,20,frac);
495
+ R.render(); return;
496
+ }
497
+
498
+ if(st.isSleeping){
499
+ const items=[];
500
+ const sp=SP[st.character];
501
+ if(sp){
502
+ const f=sp.sleep||sp.idle1;
503
+ const cx=Math.floor((32-f[0].length)/2);
504
+ const cy=Math.floor((16-f.length)/2)+1;
505
+ const offset=this.petMotion('sleep');
506
+ items.push({name:'sleeping body',pixels:f,x:cx+offset.x,y:cy+offset.y});
507
+ }
508
+ items.push({name:'sleep mark',pixels:SP.zzz,x:this.frame%60<30?23:24,y:this.frame%60<30?1:2});
509
+ drawScene(R,items);
510
+ R.render(!st.lightOn); return;
511
+ }
512
+
513
+ // Normal idle
514
+ const sp=SP[st.character];
515
+ const items=[];
516
+ if(sp){
517
+ const fk=this.frame%90<45?'idle1':'idle2';
518
+ const original=sp[fk]||sp.idle1;
519
+ const f=this.canTilt()?tiltFrame(original,clockNow()):original;
520
+ const cx=Math.floor((32-f[0].length)/2);
521
+ const cy=Math.floor((16-f.length)/2)+1;
522
+ const offset=this.petMotion();
523
+ items.push({name:`${st.character}.${fk}`,pixels:f,x:cx+offset.x,y:cy+offset.y});
524
+ }
525
+
526
+ // Poop
527
+ for(let i=0;i<st.poopCount;i++){
528
+ const px=27-(i%2)*5;
529
+ const py=11-Math.floor(i/2)*5;
530
+ items.push({name:`waste ${i+1}`,pixels:SP.poop,x:px,y:py});
531
+ }
532
+
533
+ // Skull if sick
534
+ if(st.isSick&&(still||this.frame%60<40)) items.push({name:'sickness',pixels:SP.skull,x:1,y:1});
535
+
536
+ // Misbehaving
537
+ if(st.isMisbehaving&&(still||this.frame%40<20)) items.push({name:'attention',pixels:PF['!'],x:1,y:8});
538
+
539
+ // Needs live below the LCD so they cannot overwrite the character.
540
+ drawScene(R,items);
541
+ R.render(!st.lightOn);
542
+ }
543
+
544
+ petMotion(kind='idle'){
545
+ if(!this.presentationEnabled||this.motionDisabled())return {x:0,y:0};
546
+ const elapsed=clockNow()-this.landingAt;
547
+ if(this.landingAt&&elapsed>=0&&elapsed<650)return motionOffset(elapsed,'land');
548
+ return motionOffset(clockNow(),kind);
549
+ }
550
+
551
+ canTilt(){
552
+ const st=this.state;
553
+ return this.presentationEnabled&&!this.motionDisabled()&&!st.poopCount&&!st.isSick&&!st.isMisbehaving;
554
+ }
555
+
556
+ motionDisabled(){return this.state.vacationAt>0||this.eink||globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;}
557
+
558
+ cancelGame(){
559
+ this.arcade?.cancel();
560
+ clearTimeout(this.gameReactionTimer);clearTimeout(this.gameRoundTimer);clearTimeout(this.gameCloseTimer);
561
+ this.gameActive=false;this.gamePhase='idle';this.gameId=(this.gameId||0)+1;}
562
+
563
+
564
+ renderAnim(R,st){
565
+ const sp=SP[st.character];
566
+ if(!sp) return;
567
+ const frame=sp[this.animState]||sp.eat||sp.idle1;
568
+ const cx=Math.floor((32-frame[0].length)/2);
569
+ const cy=Math.floor((16-frame.length)/2)+1;
570
+ const offset=this.petMotion(this.animState);
571
+ const items=[{name:`${st.character}.${this.animState}`,pixels:frame,x:cx+offset.x,y:cy+offset.y}];
572
+ switch(this.animState){
573
+ case 'eat':
574
+ if(this.motionDisabled()||this.frame%30<15) items.push({name:'meal',pixels:SP.meal,x:cx-6,y:cy+2});
575
+ break;
576
+ case 'evolve':
577
+ if(!this.presentationEnabled&&this.frame%20>=10) items.length=0;
578
+ break;
579
+ case 'discipline':
580
+ if(this.motionDisabled()||this.frame%20<10) items.push({name:'discipline',pixels:PF['!'],x:cx+frame[0].length+1,y:cy});
581
+ break;
582
+ case 'medicine':
583
+ if(PF['+']) items.push({name:'medicine',pixels:PF['+'],x:cx-4,y:cy});
584
+ break;
585
+ case 'clean':
586
+ items[0]=centeredBody(sp.happy||sp.idle1,`${st.character}.happy`);
587
+ break;
588
+ default:
589
+ break;
590
+ }
591
+ drawScene(R,items);
592
+ }
593
+
594
+ // -- UI --
595
+ openOverlay(id){
596
+ const overlay=document.getElementById(id);
597
+ if(overlay.classList.contains?.('show'))return;
598
+ const active=[...document.querySelectorAll('.overlay.show')];
599
+ if(!active.length)this.returnFocus=document.activeElement;
600
+ for(const previous of active)previous.classList.remove('show');
601
+ overlay.classList.add('show');
602
+ document.getElementById('device').inert=true;
603
+ overlayControls(overlay)[0]?.focus();
604
+ }
605
+ closeOverlay(id){
606
+ if(id==='game-overlay')this.cancelGame();
607
+ if(id==='arcade-overlay')this.arcade?.cancel();
608
+ const overlay=document.getElementById(id);
609
+ if(!overlay?.classList.contains?.('show'))return;
610
+ overlay.classList.remove('show');
611
+ const remaining=[...document.querySelectorAll('.overlay.show')].at(-1);
612
+ document.getElementById('device').inert=!!remaining;
613
+ if(remaining)overlayControls(remaining)[0]?.focus();
614
+ else this.returnFocus?.focus();
615
+ }
616
+
617
+ showDeath(){
618
+ this.cancelGame();
619
+ if(this.catchingUp) return;
620
+ document.getElementById('death-msg').textContent=
621
+ `${characterName(this.state.character)} lived for ${this.state.age} ${this.state.age===1?'day':'days'}. Their memory stays in Settings.`;
622
+ this.openOverlay('death-overlay');
623
+ document.getElementById('attention-bar').classList.remove('active');
624
+ }
625
+
626
+ }
@@ -0,0 +1,53 @@
1
+ // Frames stay complete. Placement moves their opaque bounds, never their pixels.
2
+ const metadata = new WeakMap();
3
+ export const screen = {left:0, top:0, right:31, bottom:15};
4
+
5
+ export function frameInfo(pixels) {
6
+ if(metadata.has(pixels)) return metadata.get(pixels);
7
+ const height=pixels?.length, width=pixels?.[0]?.length;
8
+ if(!width || !height || pixels.some(row=>row.length!==width)) throw new Error('Sprite frames must be rectangular');
9
+ let left=width,top=height,right=-1,bottom=-1,count=0;
10
+ pixels.forEach((row,y)=>row.forEach((value,x)=>{
11
+ if(!Number.isInteger(value)||value<0||value>255) throw new Error('Invalid sprite palette value');
12
+ if(value){left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y);count++;}
13
+ }));
14
+ if(!count) throw new Error('Sprite frame has no visible pixels');
15
+ const info={width,height,left,right,top,bottom,count,anchor:{x:Math.floor(width/2),y:height-1}};
16
+ metadata.set(pixels,info);
17
+ return info;
18
+ }
19
+
20
+ export const overlaps=(a,b)=>a.left<=b.right&&a.right>=b.left&&a.top<=b.bottom&&a.bottom>=b.top;
21
+
22
+ export function placeFrame(pixels,x,y,occupied=[],area=screen) {
23
+ const info=frameInfo(pixels);
24
+ const preferred={left:x+info.left,right:x+info.right,top:y+info.top,bottom:y+info.bottom};
25
+ if(Number.isInteger(x)&&Number.isInteger(y)&&preferred.left>=area.left&&preferred.right<=area.right&&preferred.top>=area.top&&preferred.bottom<=area.bottom&&!occupied.some(other=>overlaps(preferred,other))) return {x,y,bounds:preferred,distance:0};
26
+ const candidates=[];
27
+ for(let py=area.top-info.top;py<=area.bottom-info.bottom;py++)
28
+ for(let px=area.left-info.left;px<=area.right-info.right;px++) {
29
+ const bounds={left:px+info.left,right:px+info.right,top:py+info.top,bottom:py+info.bottom};
30
+ if(!occupied.some(other=>overlaps(bounds,other))) candidates.push({x:px,y:py,bounds,distance:Math.abs(px-x)+Math.abs(py-y)});
31
+ }
32
+ candidates.sort((a,b)=>a.distance-b.distance||Math.abs(a.y-y)-Math.abs(b.y-y)||a.y-b.y||a.x-b.x);
33
+ if(!candidates.length) throw new Error('Sprite composition does not fit the screen');
34
+ return candidates[0];
35
+ }
36
+
37
+ export function drawScene(renderer,items,occupied=[]) {
38
+ const placed=[];
39
+ for(const item of items) {
40
+ const position=placeFrame(item.pixels,item.x,item.y,[...occupied,...placed.map(part=>part.bounds)],item.area||screen);
41
+ renderer.drawingContext=item.name;
42
+ renderer.drawSprite(item.pixels,position.x,position.y);
43
+ placed.push({...position,name:item.name,pixels:item.pixels});
44
+ }
45
+ renderer.drawingContext=null;
46
+ renderer.lastComposition=placed;
47
+ return placed;
48
+ }
49
+
50
+ export function centeredBody(pixels,name='body') {
51
+ const {width,height}=frameInfo(pixels);
52
+ return {pixels,name,x:Math.floor((32-width)/2),y:Math.floor((16-height)/2)+1};
53
+ }