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,13 @@
|
|
|
1
|
+
// Measure natural flex content, not the iframe's current viewport height.
|
|
2
|
+
export function overlayContentHeight(overlay, styleFor = getComputedStyle) {
|
|
3
|
+
const style = styleFor(overlay);
|
|
4
|
+
const number = value => Number.parseFloat(value) || 0;
|
|
5
|
+
const children = [...overlay.children].filter(node => node.getClientRects().length);
|
|
6
|
+
const content = children.reduce((total, node) => {
|
|
7
|
+
const css = styleFor(node);
|
|
8
|
+
return total + node.getBoundingClientRect().height + number(css.marginTop) + number(css.marginBottom);
|
|
9
|
+
}, 0);
|
|
10
|
+
return Math.ceil(content + Math.max(0, children.length - 1) * number(style.rowGap)
|
|
11
|
+
+ number(style.paddingTop) + number(style.paddingBottom)
|
|
12
|
+
+ number(style.borderTopWidth) + number(style.borderBottomWidth));
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Whole device pixels keep the grid sharp at fractional browser zoom too.
|
|
2
|
+
export function pixelSize(available,dpr=1) {
|
|
3
|
+
const ratio=Number.isFinite(dpr)&&dpr>0?dpr:1;
|
|
4
|
+
const scale=Math.max(1,Math.min(Math.floor(available*ratio/32),Math.round(8*ratio)));
|
|
5
|
+
return {scale,gap:Math.max(1,Math.round(ratio)),width:32*scale,height:16*scale,cssWidth:32*scale/ratio,cssHeight:16*scale/ratio};
|
|
6
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { careGrade } from './wellbeing.js';
|
|
2
|
+
import { characterName } from './skins.js';
|
|
3
|
+
|
|
4
|
+
export function periodAt(date) {
|
|
5
|
+
const hour=date.getHours();
|
|
6
|
+
return hour>=6&&hour<11?'morning':hour>=11&&hour<16?'afternoon':hour>=16&&hour<19?'evening':'night';
|
|
7
|
+
}
|
|
8
|
+
export function careAction(state) {
|
|
9
|
+
return state.poopCount>0?'clean':state.isSleeping?'wake':'sleep';
|
|
10
|
+
}
|
|
11
|
+
export const sceneFor=(state,period)=>!state.lightOn?'dark':state.isSleeping?'sleep':period;
|
|
12
|
+
export function careHint(state) {
|
|
13
|
+
if(state.isDead)return 'Your pet has passed away. Start again when you are ready.';
|
|
14
|
+
if(state.vacationAt>0)return 'Vacation paused. Resume in Settings when you are ready.';
|
|
15
|
+
if(state.stage==='egg')return 'Your egg takes 90 seconds to hatch. Care starts after hatching.';
|
|
16
|
+
if(state.isSick)return 'Needs medicine. Open More care to help.';
|
|
17
|
+
if(state.poopCount)return 'A little cleanup will help your pet feel better.';
|
|
18
|
+
if(state.isSleeping)return 'Resting. Wake your pet before feeding or playing.';
|
|
19
|
+
if(!state.hunger)return 'Hungry. Offer a meal before playing.';
|
|
20
|
+
if(state.isMisbehaving)return 'Calling for attention with full needs. Discipline is in More care.';
|
|
21
|
+
if(!state.happiness)return 'Could use some company. Play a round together.';
|
|
22
|
+
if(state.energy<25)return 'Getting tired. A nap will restore energy.';
|
|
23
|
+
return 'Feed, play, or settle down for a nap.';
|
|
24
|
+
}
|
|
25
|
+
export function updatePresentation(state,now,renderer) {
|
|
26
|
+
const identity=document.getElementById('pet-identity');
|
|
27
|
+
if(!identity)return;
|
|
28
|
+
const date=new Date(now),period=periodAt(date),grade=careGrade(state);
|
|
29
|
+
const icons={morning:'☀',afternoon:'☀',evening:'◒',night:'☾'};
|
|
30
|
+
const clock=document.getElementById('pet-clock');
|
|
31
|
+
clock.textContent=`${icons[period]} ${date.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}`;
|
|
32
|
+
clock.setAttribute('aria-label',`${period}, ${date.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})} local time`);
|
|
33
|
+
const scene=sceneFor(state,period);
|
|
34
|
+
if(document.documentElement.dataset.scene!==scene){document.documentElement.dataset.scene=scene;renderer?.updateColors();}
|
|
35
|
+
identity.textContent=`Care: ${state.vacationAt>0?'Paused':state.isDead?'Remembered':grade.label}`;
|
|
36
|
+
identity.title='Care quality reflects your pet’s recent needs.';
|
|
37
|
+
const meters=document.getElementById('care-meters');
|
|
38
|
+
meters.hidden=state.stage==='egg'||state.isDead;
|
|
39
|
+
if(!meters.hidden)meters.innerHTML=[['Food',`${state.hunger*25}%`],['Happy',`${state.happiness*25}%`],['Energy',`${Math.round(state.energy??100)}%`],['Health',`${Math.round(state.health??100)}%`]].map(([label,value])=>`<span class="need"><span>${label}</span><strong>${value}</strong></span>`).join('');
|
|
40
|
+
const needs=document.getElementById('pet-needs');
|
|
41
|
+
needs.hidden=state.stage!=='egg';
|
|
42
|
+
document.getElementById('care-hint').textContent=careHint(state);
|
|
43
|
+
const button=document.getElementById('care-btn');
|
|
44
|
+
const action=careAction(state);
|
|
45
|
+
button.querySelector('.label').textContent=action[0].toUpperCase()+action.slice(1);
|
|
46
|
+
button.querySelector('.care-symbol').textContent=action==='clean'?'✧':action==='wake'?'☀':'☾';
|
|
47
|
+
button.setAttribute('aria-label',`${action} pet`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Integer offsets preserve complete pixel cells and stay inside placement bounds.
|
|
51
|
+
export function motionOffset(time,kind='idle',options={}) {
|
|
52
|
+
if(options.reducedMotion||options.eink||options.paused)return {x:0,y:0};
|
|
53
|
+
const elapsed=Math.max(0,Number.isFinite(time)?time:0);
|
|
54
|
+
if(kind==='land') {
|
|
55
|
+
// One short drop, then a quiet settle. Never loop a falling character.
|
|
56
|
+
if(elapsed>=650)return {x:0,y:0};
|
|
57
|
+
const progress=Math.min(1,elapsed/450);
|
|
58
|
+
return {x:0,y:Math.round(-2*(1-progress*progress))};
|
|
59
|
+
}
|
|
60
|
+
const bouncing=['eat','happy','play','evolve'].includes(kind);
|
|
61
|
+
return {x:kind==='idle'?Math.round(Math.sin(elapsed/1600)):0,y:bouncing?-Math.round((Math.sin(elapsed/180)+1)/2):kind==='sleep'?-Math.round((Math.sin(elapsed/1300)+1)/2):0};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tiltedFrames=new WeakMap();
|
|
65
|
+
export function tiltFrame(pixels,time) {
|
|
66
|
+
// Small row shear gives a pixel tilt without resampling, cropping or moving effects.
|
|
67
|
+
if(!pixels?.length||pixels[0].length>14)return pixels;
|
|
68
|
+
const wave=Math.sin(time/1900),direction=wave>.45?1:wave<-.45?-1:0;
|
|
69
|
+
if(!direction)return pixels;
|
|
70
|
+
let cached=tiltedFrames.get(pixels);
|
|
71
|
+
if(!cached){cached=new Map();tiltedFrames.set(pixels,cached);}
|
|
72
|
+
if(cached.has(direction))return cached.get(direction);
|
|
73
|
+
const width=pixels[0].length+2;
|
|
74
|
+
const result=Object.freeze(pixels.map((row,y)=>{
|
|
75
|
+
const shift=y<Math.ceil(pixels.length/2)?1+direction:1;
|
|
76
|
+
return Object.freeze([...Array(shift).fill(0),...row,...Array(width-row.length-shift).fill(0)]);
|
|
77
|
+
}));
|
|
78
|
+
cached.set(direction,result);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { C } from './constants.js';
|
|
2
|
+
import { PF } from './font.js';
|
|
3
|
+
import { SP, palette } from './skins.js';
|
|
4
|
+
import { petType } from './storage.js';
|
|
5
|
+
import { frameInfo } from './layout.js';
|
|
6
|
+
import { pixelSize } from './pixel-size.js';
|
|
7
|
+
|
|
8
|
+
export class Renderer {
|
|
9
|
+
constructor(canvas){
|
|
10
|
+
this.canvas=canvas;
|
|
11
|
+
this.ctx=canvas.getContext('2d');
|
|
12
|
+
this.w=C.W; this.h=C.H;
|
|
13
|
+
this.buf=Array.from({length:this.h},()=>new Uint8Array(this.w));
|
|
14
|
+
this.updateColors();
|
|
15
|
+
canvas.width=this.w*C.SCALE;
|
|
16
|
+
canvas.height=this.h*C.SCALE;
|
|
17
|
+
if(canvas.parentElement && typeof ResizeObserver!=='undefined') {
|
|
18
|
+
const resize=()=>{
|
|
19
|
+
const parent=canvas.parentElement;
|
|
20
|
+
const style=getComputedStyle(parent);
|
|
21
|
+
const available=parent.clientWidth-parseFloat(style.paddingLeft||0)-parseFloat(style.paddingRight||0);
|
|
22
|
+
const size=pixelSize(available,globalThis.devicePixelRatio||1);
|
|
23
|
+
this.pixelScale=size.scale;this.pixelGap=Math.min(size.scale-1,size.gap);
|
|
24
|
+
canvas.width=size.width;canvas.height=size.height;
|
|
25
|
+
canvas.style.width=`${size.cssWidth}px`;canvas.style.height=`${size.cssHeight}px`;
|
|
26
|
+
this.render(this.inverted||false);
|
|
27
|
+
};
|
|
28
|
+
this.resizeObserver=new ResizeObserver(resize);this.resizeObserver.observe(canvas.parentElement);
|
|
29
|
+
window.addEventListener('resize',resize);resize();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
updateColors(){
|
|
33
|
+
const s=getComputedStyle(document.documentElement);
|
|
34
|
+
this.bg=s.getPropertyValue('--lcd-bg').trim()||'#c4cfa1';
|
|
35
|
+
this.fg=s.getPropertyValue('--lcd-fg').trim()||'#0f380f';
|
|
36
|
+
}
|
|
37
|
+
clear(){ for(let y=0;y<this.h;y++) this.buf[y].fill(0); this.rejectedPixels=[]; this.lastComposition=[]; }
|
|
38
|
+
setPixel(x,y,v=1){
|
|
39
|
+
if(Number.isInteger(x)&&Number.isInteger(y)&&x>=0&&x<this.w&&y>=0&&y<this.h) this.buf[y][x]=v;
|
|
40
|
+
else if(v) {
|
|
41
|
+
const rejected={family:petType,frame:this.drawingContext||'unlabeled',x,y,value:v};
|
|
42
|
+
(this.rejectedPixels??=[]).push(rejected);
|
|
43
|
+
if(this.strictBounds) throw new Error(`Clipped sprite pixel: ${JSON.stringify(rejected)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
drawSprite(sp,px,py){
|
|
47
|
+
for(let y=0;y<sp.length;y++)
|
|
48
|
+
for(let x=0;x<sp[y].length;x++)
|
|
49
|
+
if(sp[y][x]) this.setPixel(px+x,py+y,sp[y][x]);
|
|
50
|
+
}
|
|
51
|
+
drawText(txt,px,py){
|
|
52
|
+
let cx=px;
|
|
53
|
+
for(const ch of txt.toUpperCase()){
|
|
54
|
+
const g=PF[ch];
|
|
55
|
+
if(g){ this.drawSprite(g,cx,py); cx+=g[0].length+1; }
|
|
56
|
+
else cx+=3;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
drawHearts(count,max,px,py){
|
|
60
|
+
for(let i=0;i<max;i++) this.drawSprite(i<count?SP.heart:SP.heartEmpty,px+i*6,py);
|
|
61
|
+
}
|
|
62
|
+
// Progress bar: x,y, width, fraction 0-1
|
|
63
|
+
drawBar(px,py,w,frac){
|
|
64
|
+
for(let x=0;x<w;x++) this.setPixel(px+x,py);
|
|
65
|
+
for(let x=0;x<w;x++) this.setPixel(px+x,py+2);
|
|
66
|
+
this.setPixel(px,py+1); this.setPixel(px+w-1,py+1);
|
|
67
|
+
const fill=Math.floor((w-2)*Math.min(1,frac));
|
|
68
|
+
for(let x=0;x<fill;x++) this.setPixel(px+1+x,py+1);
|
|
69
|
+
}
|
|
70
|
+
render(invert=false){
|
|
71
|
+
this.inverted=invert;
|
|
72
|
+
const s=this.pixelScale||C.SCALE,gap=this.pixelGap??1;
|
|
73
|
+
this.ctx.fillStyle=invert?(petType==='classic'?this.fg:'#030504'):this.bg;
|
|
74
|
+
this.ctx.fillRect(0,0,this.w*s,this.h*s);
|
|
75
|
+
this.ctx.fillStyle=invert?this.bg:this.fg;
|
|
76
|
+
for(let y=0;y<this.h;y++)
|
|
77
|
+
for(let x=0;x<this.w;x++)
|
|
78
|
+
if(this.buf[y][x]) {
|
|
79
|
+
this.ctx.fillStyle = invert ? (petType==='classic'?this.bg:'#334038') : (palette[this.buf[y][x]] || this.fg);
|
|
80
|
+
this.ctx.fillRect(x*s+gap,y*s+gap,s-gap,s-gap);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
renderToCanvas(canvas,sp,scale=4,{field=false}={}){
|
|
84
|
+
frameInfo(sp);
|
|
85
|
+
const ctx=canvas.getContext('2d');
|
|
86
|
+
if(!Number.isSafeInteger(scale)||scale<1)throw new RangeError("Sprite scale must be a positive integer");
|
|
87
|
+
const s=scale;
|
|
88
|
+
canvas.width=(field?32:sp[0].length)*s;
|
|
89
|
+
canvas.height=(field?24:sp.length)*s;
|
|
90
|
+
const offsetX=field?Math.floor((32-sp[0].length)/2)*s:0;
|
|
91
|
+
const offsetY=field?Math.floor((24-sp.length)/2)*s:0;
|
|
92
|
+
ctx.fillStyle=this.bg;
|
|
93
|
+
ctx.fillRect(0,0,canvas.width,canvas.height);
|
|
94
|
+
ctx.fillStyle=this.fg;
|
|
95
|
+
for(let y=0;y<sp.length;y++)
|
|
96
|
+
for(let x=0;x<sp[y].length;x++)
|
|
97
|
+
if(sp[y][x]) { ctx.fillStyle=palette[sp[y][x]] || this.fg; ctx.fillRect(offsetX+x*s,offsetY+y*s,s,s); }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* Separate landscape strip. The original 32 by 16 canvas stays unobstructed. */
|
|
2
|
+
.scene-window { height:24px; width:100%; position:relative; overflow:hidden; margin:0 0 7px; border-radius:10px 10px 2px 2px; background:linear-gradient(var(--sky-top,#95b9ba),var(--sky-bottom,#d7d3a4)); isolation:isolate; pointer-events:none; }
|
|
3
|
+
.scene-orb { position:absolute; width:10px; height:10px; border-radius:50%; top:4px; right:23%; background:var(--sky-orb,#f5df9f); }
|
|
4
|
+
.scene-land { position:absolute; inset:8px 0 0; background:var(--sky-land,#748761); clip-path:polygon(0 70%,12% 50%,24% 65%,40% 12%,57% 52%,74% 25%,90% 63%,100% 46%,100% 100%,0 100%); }
|
|
5
|
+
:root[data-scene="morning"] { --sky-top:#9faeac; --sky-bottom:#e5c796; --sky-orb:#ffe3a6; --sky-land:#77835c; }
|
|
6
|
+
:root[data-scene="afternoon"] { --sky-top:#86b5b9; --sky-bottom:#bbd7c7; --sky-orb:#fff0b1; --sky-land:#617f62; }
|
|
7
|
+
:root[data-scene="evening"] { --sky-top:#8c7b9a; --sky-bottom:#d69c7b; --sky-orb:#f0ba85; --sky-land:#666078; }
|
|
8
|
+
:root[data-scene="night"] { --sky-top:#1d2c42; --sky-bottom:#3b4b64; --sky-orb:#d1dace; --sky-land:#253842; }
|
|
9
|
+
:root[data-scene="night"] .scene-orb { background:transparent; box-shadow:-3px 1px 0 0 var(--sky-orb); }
|
|
10
|
+
:root[data-scene="night"] .scene-window { background:radial-gradient(circle at 22% 22%,#d2d8c7 0 1px,transparent 1.5px),radial-gradient(circle at 65% 30%,#d2d8c7 0 1px,transparent 1.5px),linear-gradient(var(--sky-top),var(--sky-bottom)); }
|
|
11
|
+
:root[data-pet="pizza"] .scene-land { background:var(--sky-land); clip-path:polygon(0 80%,18% 30%,32% 70%,50% 10%,65% 68%,83% 33%,100% 80%,100% 100%,0 100%); }
|
|
12
|
+
:root[data-pet="cyberpunk"] .scene-land { clip-path:polygon(0 60%,8% 60%,8% 20%,17% 20%,17% 45%,30% 45%,30% 0,41% 0,41% 65%,52% 65%,52% 30%,66% 30%,66% 55%,78% 55%,78% 10%,88% 10%,88% 65%,100% 65%,100% 100%,0 100%); }
|
|
13
|
+
:root[data-pet="cyberpunk"] .scene-window { box-shadow:inset 0 -1px #8cebdc55; }
|
|
14
|
+
:root[data-scene="sleep"],:root[data-scene="dark"] { --sky-top:#0d151b; --sky-bottom:#182128; --sky-land:#202b30; }
|
|
15
|
+
:root[data-scene="sleep"] .scene-orb,:root[data-scene="dark"] .scene-orb { display:none; }
|
|
16
|
+
:root.eink .scene-window { background:#eee; box-shadow:none; }
|
|
17
|
+
:root.eink .scene-land { background:#777; }
|
|
18
|
+
:root.eink .scene-orb { background:#333; box-shadow:none; }
|
|
19
|
+
.growth-card { margin:10px 0; padding:10px 12px; border:1px solid #66755e; border-radius:12px; background:#26302a; color:#edf1e8; display:flex; align-items:center; gap:10px; font:12px/1.5 system-ui,sans-serif; }
|
|
20
|
+
.growth-card[hidden] { display:none; }
|
|
21
|
+
.growth-message { margin:0; flex:1; }
|
|
22
|
+
.growth-card button { flex-shrink:0; min-height:44px; padding:8px; font:inherit; color:inherit; background:#354438; border:1px solid #778774; border-radius:9px; cursor:pointer; }
|
|
23
|
+
.growth-card button:focus-visible { outline:2px solid #e5d6a3; outline-offset:3px; }
|
|
24
|
+
@media(prefers-reduced-motion:no-preference) { :root:not(.eink) .growth-card { animation:growth-arrive .25s ease-out; } @keyframes growth-arrive { from{opacity:.5;transform:translateY(3px)} to{opacity:1;transform:none} } }
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { now as clockNow, setSimulationTime } from './clock.js';
|
|
2
|
+
import { C } from './constants.js';
|
|
3
|
+
import { Evo } from './evolution.js';
|
|
4
|
+
import { elderFor } from './original-roster.js';
|
|
5
|
+
import { advanceWellbeing, sleepDecision } from './wellbeing.js';
|
|
6
|
+
import { limitAbsence } from './away.js';
|
|
7
|
+
|
|
8
|
+
export class PetSimulation {
|
|
9
|
+
processMissedTime(){
|
|
10
|
+
const end=clockNow(), st=this.state;
|
|
11
|
+
if(st.vacationAt>0)return;
|
|
12
|
+
if(st.isDead) { this.showDeath(); return; }
|
|
13
|
+
const previous={...st};
|
|
14
|
+
const skipped=limitAbsence(st,end);
|
|
15
|
+
const start=st.lastProcessed;
|
|
16
|
+
if(end<=start) return;
|
|
17
|
+
if(st.wellbeingUpdatedAt<start)st.wellbeingUpdatedAt=start;
|
|
18
|
+
const sound=this.sound;
|
|
19
|
+
this.sound=new Proxy({}, {get:()=>()=>{}});
|
|
20
|
+
this.catchingUp=true;
|
|
21
|
+
try {
|
|
22
|
+
for(let time=Math.min(start+60000,end);;time=Math.min(time+60000,end)) {
|
|
23
|
+
setSimulationTime(time);
|
|
24
|
+
this.tick();
|
|
25
|
+
if(Math.floor(time/600000)>Math.floor((time-60000)/600000)) this.maybeMisbehave();
|
|
26
|
+
if(time===end || st.isDead) break;
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
setSimulationTime(null);
|
|
30
|
+
this.sound=sound;
|
|
31
|
+
this.catchingUp=false;
|
|
32
|
+
this.animState=null;
|
|
33
|
+
}
|
|
34
|
+
this.state.lastProcessed=end;
|
|
35
|
+
this.state.save();
|
|
36
|
+
this.growth?.observe(previous,st);
|
|
37
|
+
if(st.isDead) this.showDeath();
|
|
38
|
+
this.updateButtons();
|
|
39
|
+
if(skipped>0)this.toast('Welcome back. Only eight hours passed for your pet.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// -- GAME TICK --
|
|
43
|
+
tick(){
|
|
44
|
+
const st=this.state, now=clockNow();
|
|
45
|
+
if(st.vacationAt>0)return;
|
|
46
|
+
advanceWellbeing(st,now);
|
|
47
|
+
st.lastProcessed=now;
|
|
48
|
+
if(st.isDead) return;
|
|
49
|
+
|
|
50
|
+
if(st.stage==='egg'){
|
|
51
|
+
if(Evo.shouldEvolve(st)) this.doEvolve();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if(now-st.lastAgeInc>=86400000){ st.age++; st.lastAgeInc+=86400000; }
|
|
56
|
+
this.checkSleep();
|
|
57
|
+
if(st.isSleeping) return;
|
|
58
|
+
|
|
59
|
+
const hRate=C.HUNGER_DECAY[st.stage]||3600000;
|
|
60
|
+
if(now-st.lastHungerDecay>=hRate){
|
|
61
|
+
if(st.hunger>0) st.hunger--;
|
|
62
|
+
st.lastHungerDecay=now;
|
|
63
|
+
}
|
|
64
|
+
const hpRate=C.HAPPY_DECAY[st.stage]||3600000;
|
|
65
|
+
if(now-st.lastHappyDecay>=hpRate){
|
|
66
|
+
if(st.happiness>0) st.happiness--;
|
|
67
|
+
st.lastHappyDecay=now;
|
|
68
|
+
}
|
|
69
|
+
const pRate=C.POOP_RATE[st.stage]||10800000;
|
|
70
|
+
if(now-st.lastPoopTime>=pRate&&st.poopCount<C.MAX_POOP){
|
|
71
|
+
st.poopCount++; st.lastPoopTime=now;
|
|
72
|
+
}
|
|
73
|
+
if(st.poopCount>=3&&!st.isSick&&Math.random()<(this.catchingUp ? 1-Math.pow(.99,60) : .01)) this.makeSick();
|
|
74
|
+
|
|
75
|
+
this.checkAttention();
|
|
76
|
+
|
|
77
|
+
if(st.attentionCallTime&&now-st.attentionCallTime>=C.CARE_WINDOW){
|
|
78
|
+
st.careMistakes++;
|
|
79
|
+
st.attentionCallTime=null; st.attentionReason=null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if(Evo.shouldEvolve(st)&&!st.isSick) this.doEvolve();
|
|
83
|
+
|
|
84
|
+
if(st.stage==='adult'){
|
|
85
|
+
if(st.careMistakes>=C.ADULT_MAX_MISTAKES||st.age>=C.MAX_AGE){
|
|
86
|
+
st.isDead=true; st.stage='dead';
|
|
87
|
+
this.cancelGame();
|
|
88
|
+
st.save();
|
|
89
|
+
this.sound.death(); this.showDeath();
|
|
90
|
+
}
|
|
91
|
+
if(!st.isDead&&['maskutchi','zuccitchi'].includes(st.character)&&st.discipline>=100&&st.age>=10&&Math.random()<.05){
|
|
92
|
+
const elder=elderFor(st);
|
|
93
|
+
if(!elder)return;
|
|
94
|
+
const previous={...st};
|
|
95
|
+
st.character=elder;
|
|
96
|
+
this.sound.hatch();
|
|
97
|
+
this.animState='evolve'; this.animTimer=clockNow();
|
|
98
|
+
if(!this.catchingUp){this.growth?.observe(previous,st);this.landingAt=clockNow()+2000;}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if(this.catchingUp) return;
|
|
103
|
+
const bar=document.getElementById('attention-bar');
|
|
104
|
+
bar.classList.toggle('active',st.attentionCallTime!=null);
|
|
105
|
+
|
|
106
|
+
if(st.attentionCallTime&&now%4000<1000) this.sound.attention();
|
|
107
|
+
|
|
108
|
+
this.updateButtons();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
checkSleep(){
|
|
112
|
+
const st=this.state, h=new Date(clockNow()).getHours();
|
|
113
|
+
const sch=C.SLEEP[st.stage];
|
|
114
|
+
if(!sch) return;
|
|
115
|
+
const shouldSleep=sleepDecision(st,(h>=sch.s||h<sch.w),clockNow());
|
|
116
|
+
if(shouldSleep&&!st.isSleeping){
|
|
117
|
+
st.isSleeping=true;
|
|
118
|
+
}
|
|
119
|
+
if(!shouldSleep&&st.isSleeping){
|
|
120
|
+
st.isSleeping=false; st.lightOn=true;
|
|
121
|
+
st.lastHungerDecay=clockNow(); st.lastHappyDecay=clockNow(); st.lastPoopTime=clockNow();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
checkAttention(){
|
|
126
|
+
const st=this.state;
|
|
127
|
+
if(st.isSick&&!st.attentionCallTime){
|
|
128
|
+
st.attentionCallTime=clockNow(); st.attentionReason='sick'; return;
|
|
129
|
+
}
|
|
130
|
+
if(st.hunger===0&&!st.attentionCallTime&&!st.isMisbehaving){
|
|
131
|
+
st.attentionCallTime=clockNow(); st.attentionReason='hungry';
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if(st.happiness===0&&!st.attentionCallTime&&!st.isMisbehaving){
|
|
135
|
+
st.attentionCallTime=clockNow(); st.attentionReason='sad';
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if(!st.attentionCallTime&&st.isMisbehaving){
|
|
139
|
+
st.attentionCallTime=clockNow(); st.attentionReason='misbehave';
|
|
140
|
+
}
|
|
141
|
+
if(st.attentionCallTime&&!st.isSick&&st.hunger>0&&st.happiness>0&&!st.isMisbehaving){
|
|
142
|
+
st.attentionCallTime=null; st.attentionReason=null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
makeSick(){
|
|
147
|
+
const st=this.state;
|
|
148
|
+
st.isSick=true;
|
|
149
|
+
st.medDoses=1+Math.floor(Math.random()*3);
|
|
150
|
+
st.sickCount++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
doEvolve(){
|
|
154
|
+
const st=this.state;
|
|
155
|
+
const r=Evo.evolve(st);
|
|
156
|
+
if(!r) return;
|
|
157
|
+
const previous={...st};
|
|
158
|
+
const oldStage=st.stage;
|
|
159
|
+
st.stage=r.stage; st.character=r.character; st.weight=r.weight;
|
|
160
|
+
st.stageStartTime=clockNow();
|
|
161
|
+
if(oldStage==='egg'){
|
|
162
|
+
st.hunger=0; st.happiness=0;
|
|
163
|
+
this.checkAttention();
|
|
164
|
+
}
|
|
165
|
+
if(r.stage==='teen') st.prevTeen=r.character;
|
|
166
|
+
this.sound.hatch();
|
|
167
|
+
this.animState='evolve'; this.animTimer=clockNow();
|
|
168
|
+
if(!this.catchingUp){this.growth?.observe(previous,st);this.landingAt=clockNow()+2000;}
|
|
169
|
+
const name=this.evolutionName?.(r.character)||r.character;
|
|
170
|
+
this.toast(`Evolved into ${name}!`);
|
|
171
|
+
if(!this.catchingUp) this.state.save();
|
|
172
|
+
this.updateButtons();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
maybeMisbehave(){
|
|
176
|
+
const st=this.state;
|
|
177
|
+
if(st.vacationAt>0)return;
|
|
178
|
+
if(st.isDead||st.isSleeping||st.stage==='egg'||st.stage==='baby') return;
|
|
179
|
+
if(Math.random()<.05&&!st.isMisbehaving&&st.hunger>0&&st.happiness>0){
|
|
180
|
+
st.isMisbehaving=true;
|
|
181
|
+
this.updateButtons();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { lilgotchiSprites, lilgotchiPalette, lilgotchiNames } from './lilgotchi-sprites.js';
|
|
2
|
+
import { sliceSprites, slicePalette, sliceNames } from './slice-sprites.js';
|
|
3
|
+
import { cyberpunkSprites, cyberpunkPalette, cyberpunkNames } from './cyberpunk-sprites.js';
|
|
4
|
+
import { classicSprites } from './classic-sprites.js';
|
|
5
|
+
import { originalSprites } from './original-sprites.js';
|
|
6
|
+
import { petType } from './storage.js';
|
|
7
|
+
|
|
8
|
+
export const SP = structuredClone(classicSprites);
|
|
9
|
+
if (petType === 'classic') Object.assign(SP, originalSprites);
|
|
10
|
+
export const palette = petType === 'pizza' ? slicePalette : petType === 'cyberpunk' ? cyberpunkPalette : petType === 'lilgotchi' ? lilgotchiPalette : [];
|
|
11
|
+
if (petType === 'pizza') Object.assign(SP, sliceSprites);
|
|
12
|
+
if (petType === 'cyberpunk') Object.assign(SP, cyberpunkSprites);
|
|
13
|
+
if (petType === 'lilgotchi') Object.assign(SP, lilgotchiSprites);
|
|
14
|
+
export const characterName = name => petType === 'pizza' ? sliceNames[name] : petType === 'cyberpunk' ? cyberpunkNames[name] : petType === 'lilgotchi' ? lilgotchiNames[name] : name.charAt(0).toUpperCase()+name.slice(1);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const palette=['','#fff0c2','#b96b32','#ffd15a','#cf432f','#38271f','#5f9c58','#dfb889','#ffe66a','#92acb8','#b975c5','#713c24','#9e3028'];
|
|
2
|
+
const drawings=[
|
|
3
|
+
['egg','Dough','egg',[4,6],['000000000000','000000000000','000055550000','000511115000','005111111500','005111111500','000511115000','000055550000']],
|
|
4
|
+
['babytchi','Dough Drop','baby',[4,6],['000005500000','000051150000','000511115000','005111111500','005111111500','005111111500','000511115000','000051150000','000055055000']],
|
|
5
|
+
['marutchi','Pizza Puff','child',[4,6],['000055550000','000522225000','005233332500','052443344250','052333333250','523333333325','523333333325','052333333250','005222222500','000550055000']],
|
|
6
|
+
['tamatchi','Little Slice','teen',[4,5],['005555555500','052222222250','005333333500','005333333500','000533335000','000533335000','000053350000','000053350000','000005500000','000055055000']],
|
|
7
|
+
['kuchitamatchi','Folded Slice','teen',[4,5],['000055555000','000522222500','005333332500','053333332500','053333332500','005333325000','000533325000','000053325000','000005335000','000055005500']],
|
|
8
|
+
['mametchi','Margherita','adult',[4,5],['055555555550','522222222225','053114461350','053114461350','005333113500','005333333500','000533335000','000534435000','000056150000','000005500000','000055055000']],
|
|
9
|
+
['ginjirotchi','Pepperoni','adult',[4,5],['055555555550','522222222225','053443344350','053443344350','005333333500','005333333500','000533335000','000534435000','000054450000','000005500000','000055055000']],
|
|
10
|
+
['maskutchi','BBQ Chicken','adult',[4,5],['005555555500','052222222250','05377BAA7350','053BB7A7B350','005A33733500','005333333500','0053333B3500','0005377A5000','00005BB50000','000053350000','000005500000','000055055000']],
|
|
11
|
+
['kuchipatchi','Meat Lovers','adult',[4,6],['005555555500','052222222250','5244BB3BB425','5234BB3BB325','52C333333C25','52C333333C25','523333333325','5244C3BB4325','052222222250','005555555500','000550055000']],
|
|
12
|
+
['nyorotchi','Anchovy','adult',[4,5],['000555555000','005222222500','005393339500','005339993500','005393339500','005333333500','000533335000','000539935000','000053950000','000053350000','000005500000','000055055000']],
|
|
13
|
+
['tarakotchi','Pineapple','adult',[4,5],['005555555500','052222222250','05188C188150','05188C188150','005133133500','005333333500','0053333C1500','000531885000','00005C850000','000053150000','000005500000','000055055000']],
|
|
14
|
+
['oyajitchi','Spoiled Slice','adult',[4,5],['005555555500','052222222250','051166111150','005166611500','005111111500','005111111500','000511115000','000516615000','000051150000','000051150000','000005155000','000055055000']],
|
|
15
|
+
];
|
|
16
|
+
const faces={idle1:['505','000','050'],idle2:['505','000','555'],happy:['050','505','555'],sad:['000','505','050'],eat:['505','050','555'],sleep:['000','505','000'],lookLeft:['550','500','050'],lookRight:['055','005','050'],lookUp:['505','000','050']};
|
|
17
|
+
function canvas(rows){
|
|
18
|
+
if(rows.length>12||rows.some(row=>row.length>12))throw new Error('Slice proposal exceeds its display');
|
|
19
|
+
return [...Array(12-rows.length).fill(''),...rows].map(row=>[...row.padEnd(12,'0')].map(value=>parseInt(value,16)));
|
|
20
|
+
}
|
|
21
|
+
function pose(rows,face,expression){
|
|
22
|
+
const pixels=canvas(rows);
|
|
23
|
+
if(expression)faces[expression].forEach((row,dy)=>[...row].forEach((value,dx)=>{if(value!=='0')pixels[face[1]+dy-(expression==='lookUp'?1:0)][face[0]+dx]=Number(value);}));
|
|
24
|
+
if(expression==='idle2'){
|
|
25
|
+
const foot=pixels[11].findIndex(value=>value===5);
|
|
26
|
+
if(foot>0){pixels[11][foot]=0;pixels[11][foot-1]=5;}
|
|
27
|
+
}
|
|
28
|
+
return pixels;
|
|
29
|
+
}
|
|
30
|
+
const artwork={type:'Slice',palette,pets:drawings.map(([id,name,stage,face,rows])=>({
|
|
31
|
+
id,name,stage,
|
|
32
|
+
frames:id==='egg'?{idle1:pose(rows,face),idle2:pose(rows.map(row=>'0'+row.slice(0,-1)),face)}:Object.fromEntries(Object.keys(faces).map(expression=>[expression,pose(rows,face,expression)]))
|
|
33
|
+
}))};
|
|
34
|
+
|
|
35
|
+
const freezeFrame=frame=>Object.freeze(frame.map(row=>Object.freeze([...row])));
|
|
36
|
+
export const slicePalette=Object.freeze(artwork.palette);
|
|
37
|
+
export const sliceNames=Object.freeze(Object.fromEntries(artwork.pets.map(pet=>[pet.id,pet.name])));
|
|
38
|
+
export const sliceSprites=Object.freeze(Object.fromEntries(artwork.pets.map(pet=>[pet.id,Object.freeze(Object.fromEntries(Object.entries(pet.frames).map(([key,frame])=>[key,freezeFrame(pet.id==='egg'?frame.slice(2):frame)])))])));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class SoundManager {
|
|
2
|
+
constructor(){ this.ctx=null; this.muted=true; }
|
|
3
|
+
init(){
|
|
4
|
+
try {
|
|
5
|
+
if(!this.ctx) this.ctx=new (window.AudioContext||window.webkitAudioContext)();
|
|
6
|
+
if(this.ctx.state==='suspended') this.ctx.resume().catch(()=>{});
|
|
7
|
+
}catch(e){}
|
|
8
|
+
}
|
|
9
|
+
beep(freq,dur,vol=.15){
|
|
10
|
+
if(this.muted||this.suspended||!this.ctx) return;
|
|
11
|
+
try{
|
|
12
|
+
const o=this.ctx.createOscillator(),g=this.ctx.createGain();
|
|
13
|
+
o.type='square'; o.frequency.value=freq; g.gain.value=vol;
|
|
14
|
+
o.connect(g); g.connect(this.ctx.destination);
|
|
15
|
+
o.start(); o.stop(this.ctx.currentTime+dur/1000);
|
|
16
|
+
}catch(e){}
|
|
17
|
+
}
|
|
18
|
+
btnPress(){ this.beep(800,50); }
|
|
19
|
+
attention(){ this.beep(1000,100); setTimeout(()=>this.beep(1200,100),150); }
|
|
20
|
+
happy(){ this.beep(600,80); setTimeout(()=>this.beep(900,120),100); }
|
|
21
|
+
sad(){ this.beep(300,200); }
|
|
22
|
+
gameWin(){ [0,100,200].forEach((d,i)=>setTimeout(()=>this.beep(600+i*200,100),d)); }
|
|
23
|
+
gameLose(){ this.beep(400,100); setTimeout(()=>this.beep(200,200),120); }
|
|
24
|
+
death(){ this.beep(200,500,.1); }
|
|
25
|
+
hatch(){ [0,150,300,450].forEach((d,i)=>setTimeout(()=>this.beep(500+i*100,80),d)); }
|
|
26
|
+
eat(){ this.beep(500,60); setTimeout(()=>this.beep(600,60),80); }
|
|
27
|
+
clean(){ this.beep(700,50); setTimeout(()=>this.beep(900,50),60); setTimeout(()=>this.beep(1100,50),120); }
|
|
28
|
+
medicine(){ this.beep(400,80); setTimeout(()=>this.beep(600,80),100); setTimeout(()=>this.beep(800,80),200); }
|
|
29
|
+
discipline(){ this.beep(200,100); setTimeout(()=>this.beep(400,100),120); }
|
|
30
|
+
denied(){ this.beep(200,150,.1); }
|
|
31
|
+
toggleMute(){ this.muted=!this.muted; }
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { C } from './constants.js';
|
|
2
|
+
import { now } from './clock.js';
|
|
3
|
+
import { petType } from './storage.js';
|
|
4
|
+
import { characterName } from './skins.js';
|
|
5
|
+
import { isP2 } from './original-roster.js';
|
|
6
|
+
|
|
7
|
+
export function updateStatus(state) {
|
|
8
|
+
const status = document.getElementById('pet-status');
|
|
9
|
+
if (!status) return;
|
|
10
|
+
let text;
|
|
11
|
+
if (state.stage === 'egg') {
|
|
12
|
+
const seconds = Math.max(0, Math.ceil((C.EGG_MS - ((state.vacationAt || now()) - state.stageStartTime)) / 1000));
|
|
13
|
+
text = `hatching in ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}. your egg takes 90 seconds.`;
|
|
14
|
+
} else if (state.isDead) text = `lived for ${state.age} days. start again when you’re ready.`;
|
|
15
|
+
else text = `${state.stage} · ${state.age} days · food ${state.hunger}/4 · happy ${state.happiness}/4${state.isSleeping ? ' · sleeping' : ''}${state.isSick ? ' · needs medicine' : ''}${state.isMisbehaving ? ' · needs discipline' : ''}`;
|
|
16
|
+
if(state.vacationAt>0)text = `Vacation paused. ${text}`;
|
|
17
|
+
status.textContent = ((petType === 'pizza' || petType === 'cyberpunk' || petType === 'lilgotchi') && state.stage !== 'egg' && !state.isDead ? characterName(state.character)+' · ' : '') + text.replace(/ · food \d\/4 · happy \d\/4/,'');
|
|
18
|
+
status.hidden=state.stage==='egg';
|
|
19
|
+
const needs=document.getElementById('pet-needs');
|
|
20
|
+
if(needs) {
|
|
21
|
+
const hearts=count=>'♥'.repeat(count)+'♡'.repeat(4-count);
|
|
22
|
+
needs.textContent=state.stage==='egg'?`${characterName('egg').toUpperCase()} · ${text.split('.')[0]}`:state.isDead?'Rest in peace':`Food ${hearts(state.hunger)} ${state.hunger}/4 · Happy ${hearts(state.happiness)} ${state.happiness}/4`;
|
|
23
|
+
}
|
|
24
|
+
const familyName={classic:isP2(state)?'Gen 2':'Gen 1',pizza:'Slice',cyberpunk:'Cypunk',lilgotchi:'Lilgotchi'}[petType]||petType;
|
|
25
|
+
document.getElementById('lcd').setAttribute('aria-label', `${familyName} pet. ${text}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { frameCharacter } from './catalog.js';
|
|
2
|
+
export const selectedCharacter=frameCharacter(new URLSearchParams(globalThis.location?.search || ''));
|
|
3
|
+
export const petTypes = ['classic', 'pizza', 'cyberpunk', 'lilgotchi'];
|
|
4
|
+
export const petType = selectedCharacter.skin;
|
|
5
|
+
export const saveKey = ['default','slice','cyberpunk'].includes(selectedCharacter.id) ? `taylor-pocket-pets-p1-v1-${petType}` : `lilgotchi:standalone:${selectedCharacter.id}`;
|
|
6
|
+
import { validState } from './valid-state.js';
|
|
7
|
+
export { validState } from './valid-state.js';
|
|
8
|
+
let bridge = null;
|
|
9
|
+
export function configureStorage(adapter) { bridge = adapter; }
|
|
10
|
+
|
|
11
|
+
function status(text) {
|
|
12
|
+
const element = globalThis.document?.getElementById('save-status');
|
|
13
|
+
if (element) element.textContent = text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveState(state) {
|
|
17
|
+
if (bridge) { bridge.save(JSON.parse(JSON.stringify(state))); return true; }
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(saveKey, JSON.stringify(state));
|
|
20
|
+
status('saved in this browser');
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
status('saving unavailable. progress lasts for this visit.');
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadState(state) {
|
|
29
|
+
if (bridge) {
|
|
30
|
+
const data = bridge.load();
|
|
31
|
+
if (!validState(data)) return false;
|
|
32
|
+
for (const key of Object.keys(state)) if (Object.hasOwn(data,key)) state[key]=data[key];
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const raw = localStorage.getItem(saveKey);
|
|
37
|
+
if (!raw) return false;
|
|
38
|
+
const data = JSON.parse(raw);
|
|
39
|
+
if (!validState(data)) return false;
|
|
40
|
+
// Only copy known state fields, not arbitrary properties from saved JSON.
|
|
41
|
+
for (const key of Object.keys(state)) if (Object.hasOwn(data, key)) state[key] = data[key];
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearState() {
|
|
47
|
+
if (bridge) return;
|
|
48
|
+
try { localStorage.removeItem(saveKey); } catch {}
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { p1Stages, p2Stages } from './original-roster.js';
|
|
2
|
+
export const characters = [...new Set([...Object.values(p1Stages).flat(),...Object.values(p2Stages).flat()])];
|
|
3
|
+
const stages = ['egg','baby','child','teen','adult','dead'];
|
|
4
|
+
|
|
5
|
+
export function validState(data) {
|
|
6
|
+
if (!data || !stages.includes(data.stage) || !characters.includes(data.character)) return false;
|
|
7
|
+
for (const key of ['age','weight','hunger','happiness','discipline','careMistakes','poopCount','bornAt','stageStartTime','lastHungerDecay','lastHappyDecay','lastPoopTime','lastAgeInc','lastProcessed','snacksInWindow','snackWindowStart','medDoses','sickCount']) {
|
|
8
|
+
if (!Number.isFinite(data[key]) || data[key] < 0) return false;
|
|
9
|
+
}
|
|
10
|
+
if (data.hunger > 4 || data.happiness > 4 || data.discipline > 100 || data.poopCount > 4) return false;
|
|
11
|
+
if (![data.hunger,data.happiness,data.discipline,data.poopCount].every(Number.isInteger)) return false;
|
|
12
|
+
// Older saves omit these fields. Present fields must still be safe to import.
|
|
13
|
+
for (const key of ['energy','health']) if (key in data && (!Number.isFinite(data[key]) || data[key]<0 || data[key]>100)) return false;
|
|
14
|
+
for (const key of ['wellbeingUpdatedAt','careTotal','careDuration','sleepOverrideUntil']) if (key in data && (!Number.isFinite(data[key]) || data[key]<0)) return false;
|
|
15
|
+
if ('sleepOverride' in data && !['auto','sleep','wake'].includes(data.sleepOverride)) return false;
|
|
16
|
+
if (data.careTotal > data.careDuration * 100 + 1) return false;
|
|
17
|
+
if (('careTotal' in data) !== ('careDuration' in data)) return false;
|
|
18
|
+
if (('sleepOverride' in data) !== ('sleepOverrideUntil' in data)) return false;
|
|
19
|
+
if (data.wellbeingUpdatedAt > data.lastProcessed + 300000) return false;
|
|
20
|
+
if ('vacationAt' in data && (!Number.isFinite(data.vacationAt) || data.vacationAt<0 || data.vacationAt>data.lastProcessed+300000)) return false;
|
|
21
|
+
if (data.vacationAt>0 && data.vacationAt<data.lastProcessed) return false;
|
|
22
|
+
if (data.sleepOverride !== undefined && data.sleepOverride !== 'auto' && (!Number.isFinite(data.wellbeingUpdatedAt) || data.sleepOverrideUntil > data.wellbeingUpdatedAt + 7500000)) return false;
|
|
23
|
+
for (const key of ['isSick','isSleeping','lightOn','isDead','isMisbehaving']) if (typeof data[key] !== 'boolean') return false;
|
|
24
|
+
return data.attentionCallTime === null || Number.isFinite(data.attentionCallTime);
|
|
25
|
+
}
|
|
26
|
+
|