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.
- package/LICENSE.md +21 -0
- package/README.md +69 -3
- package/dist/LICENSE.md +21 -0
- package/dist/README.md +69 -0
- package/dist/design/evolution/README.md +7 -0
- package/dist/design/evolution/cypunk.js +3 -0
- package/dist/design/evolution/cypunk.md +20 -0
- package/dist/design/evolution/index.html +20 -0
- package/dist/design/evolution/lilgotchi.js +3 -0
- package/dist/design/evolution/lilgotchi.md +20 -0
- package/dist/design/evolution/preview.css +3 -0
- package/dist/design/evolution/preview.js +47 -0
- package/dist/design/evolution/slice.js +3 -0
- package/dist/design/evolution/slice.md +14 -0
- package/dist/design/games/README.md +52 -0
- package/dist/design/games/bump.md +23 -0
- package/dist/design/games/catalog.md +24 -0
- package/dist/design/games/flag.md +19 -0
- package/dist/design/games/get.md +21 -0
- package/dist/design/games/heading.md +23 -0
- package/dist/design/games/hoops.md +17 -0
- package/dist/design/games/left-right.md +30 -0
- package/dist/design/games/memory.md +21 -0
- package/dist/design/games/polish-review.md +11 -0
- package/dist/design/games/release-checklist.md +52 -0
- package/dist/design/games/sprint.md +21 -0
- package/dist/docs/README.md +14 -0
- package/dist/docs/care.md +51 -0
- package/dist/docs/characters.md +51 -0
- package/dist/docs/credits.md +17 -0
- package/dist/docs/development.md +87 -0
- package/dist/docs/frame-contract.md +38 -0
- package/dist/docs/images/cypunk-family.jpg +0 -0
- package/dist/docs/images/lilgotchi-family.jpg +0 -0
- package/dist/docs/images/original-family.jpg +0 -0
- package/dist/docs/images/slice-family.jpg +0 -0
- package/dist/docs/install.md +41 -0
- package/dist/docs/release-review.md +47 -0
- package/dist/embed.js +21 -0
- package/dist/examples/cross-origin.html +7 -0
- package/dist/examples/curated.html +12 -0
- package/dist/examples/duplicate.html +3 -0
- package/dist/examples/install.html +11 -0
- package/dist/examples/launcher-review.html +23 -0
- package/dist/examples/launchers.html +10 -0
- package/dist/examples/nested/index.html +6 -0
- package/dist/examples/one-line.html +9 -0
- package/dist/index.html +100 -0
- package/dist/lilgotchi.js +437 -0
- package/dist/pets/NOTICE.md +19 -0
- package/dist/pets/README.md +7 -0
- package/dist/pets/arcade-catalog.js +52 -0
- package/dist/pets/arcade-geometry.js +18 -0
- package/dist/pets/arcade-opponents.js +16 -0
- package/dist/pets/arcade-renderer.js +116 -0
- package/dist/pets/arcade-sprites.js +64 -0
- package/dist/pets/arcade.js +393 -0
- package/dist/pets/away.js +20 -0
- package/dist/pets/boot.js +161 -0
- package/dist/pets/catalog.js +32 -0
- package/dist/pets/celebrations.js +54 -0
- package/dist/pets/classic-sprites.js +672 -0
- package/dist/pets/clock.js +3 -0
- package/dist/pets/colors.js +38 -0
- package/dist/pets/constants.js +20 -0
- package/dist/pets/custom-themes.css +80 -0
- package/dist/pets/cyberpunk-sprites.js +68 -0
- package/dist/pets/cypunk-original.js +2 -0
- package/dist/pets/embed.css +130 -0
- package/dist/pets/engine.js +38 -0
- package/dist/pets/evolution.js +46 -0
- package/dist/pets/focus.js +27 -0
- package/dist/pets/font.js +185 -0
- package/dist/pets/game.css +275 -0
- package/dist/pets/game.html +148 -0
- package/dist/pets/game.js +626 -0
- package/dist/pets/layout.js +53 -0
- package/dist/pets/lilgotchi-sprites.js +62 -0
- package/dist/pets/minigames/bump.js +52 -0
- package/dist/pets/minigames/flag.js +87 -0
- package/dist/pets/minigames/get.js +59 -0
- package/dist/pets/minigames/heading.js +117 -0
- package/dist/pets/minigames/hoops.js +96 -0
- package/dist/pets/minigames/memory.js +81 -0
- package/dist/pets/minigames/model.js +40 -0
- package/dist/pets/minigames/sprint.js +103 -0
- package/dist/pets/original-roster.js +46 -0
- package/dist/pets/original-sprites.js +3861 -0
- package/dist/pets/overlay-size.js +13 -0
- package/dist/pets/pixel-size.js +6 -0
- package/dist/pets/presentation.js +80 -0
- package/dist/pets/renderer.js +100 -0
- package/dist/pets/scenery.css +24 -0
- package/dist/pets/simulation.js +185 -0
- package/dist/pets/skins.js +14 -0
- package/dist/pets/slice-sprites.js +38 -0
- package/dist/pets/sound.js +33 -0
- package/dist/pets/status.js +28 -0
- package/dist/pets/storage.js +49 -0
- package/dist/pets/valid-state.js +26 -0
- package/dist/pets/wellbeing.js +73 -0
- package/dist/reference/source.json +8 -0
- package/dist/version.json +5 -0
- package/dist/widget/care-guide.js +50 -0
- package/dist/widget/care-monitor.js +52 -0
- package/dist/widget/color-controls.js +15 -0
- package/dist/widget/configurator.js +24 -0
- package/dist/widget/egg.svg +1 -0
- package/dist/widget/focus.js +6 -0
- package/dist/widget/gallery.js +17 -0
- package/dist/widget/inactive.js +24 -0
- package/dist/widget/install-snippet.js +14 -0
- package/dist/widget/launcher.js +63 -0
- package/dist/widget/pet-history.js +57 -0
- package/dist/widget/pet-label.js +12 -0
- package/dist/widget/save-store.js +198 -0
- package/dist/widget/scroll-lock.js +11 -0
- package/dist/widget/settings.js +48 -0
- package/dist/widget/shell.css +63 -0
- 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
|
+
}
|