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,18 @@
1
+ export const FIELD_WIDTH = 32;
2
+ export const FIELD_HEIGHT = 24;
3
+
4
+ export function headingPose(sprite, center, jump = 0) {
5
+ const width = Math.max(...sprite.map(row => row.length));
6
+ const left = Math.max(0, Math.min(FIELD_WIDTH - width, Math.round(center - width / 2)));
7
+ const top = Math.round(FIELD_HEIGHT - sprite.length - jump);
8
+ const occupiedRows = sprite.flatMap((row, index) => row.some(Boolean) ? [index] : []);
9
+ const highest = Math.min(...occupiedRows);
10
+ const lowest = Math.max(...occupiedRows);
11
+ const headLimit = highest + Math.floor((lowest - highest) / 2);
12
+ const head = [];
13
+ for (let column = 0; column < width; column++) {
14
+ const row = sprite.findIndex(line => !!line[column]);
15
+ if (row >= 0 && row <= headLimit) head.push({x:left + column, y:top + row});
16
+ }
17
+ return {left, top, width, height:sprite.length, head};
18
+ }
@@ -0,0 +1,16 @@
1
+ import { originalSprites } from './original-sprites.js';
2
+ import { sliceSprites, slicePalette } from './slice-sprites.js';
3
+ import { cyberpunkSprites, cyberpunkPalette } from './cyberpunk-sprites.js';
4
+ import { lilgotchiSprites, lilgotchiPalette } from './lilgotchi-sprites.js';
5
+
6
+ const opponents = [
7
+ {family:'pizza',name:'Slice',frames:sliceSprites.mametchi,palette:slicePalette},
8
+ {family:'cyberpunk',name:'Cypunk',frames:cyberpunkSprites.ginjirotchi,palette:cyberpunkPalette},
9
+ {family:'lilgotchi',name:'Lilgotchi',frames:lilgotchiSprites.mametchi,palette:lilgotchiPalette},
10
+ {family:'classic',name:'Gen 1',frames:originalSprites.mametchi,palette:[null,'#c4cfa1']},
11
+ ];
12
+
13
+ export function chooseOpponent(family, random = Math.random) {
14
+ const choices = opponents.filter(opponent => opponent.family !== family);
15
+ return choices[Math.min(choices.length-1, Math.floor(random()*choices.length))];
16
+ }
@@ -0,0 +1,116 @@
1
+ import { classicSprites } from './classic-sprites.js';
2
+ import { FIELD_WIDTH, FIELD_HEIGHT, headingPose } from './arcade-geometry.js';
3
+
4
+ const arrowPixels = {
5
+ left:[[2,0],[1,1],[0,2],[1,3],[2,4],[1,2],[2,2],[3,2],[4,2]],
6
+ right:[[2,0],[3,1],[4,2],[3,3],[2,4],[0,2],[1,2],[2,2],[3,2]],
7
+ up:[[2,0],[1,1],[3,1],[0,2],[4,2],[2,1],[2,2],[2,3],[2,4]],
8
+ };
9
+
10
+ export function drawArcade(q, s, sprite, palette = [], options = {}) {
11
+ const reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
12
+ const frames = options.frames || {idle1:sprite};
13
+ const idle = frames.idle1 || sprite;
14
+ const opponent = options.opponent;
15
+ const rivalFrames = opponent?.frames || classicSprites.mametchi;
16
+ const foreground = options.foreground || '#0f380f';
17
+ const background = options.background || '#c4cfa1';
18
+ const field = q('.arcade-field');
19
+ field.width = FIELD_WIDTH; field.height = FIELD_HEIGHT;
20
+ const ctx = field.getContext('2d');
21
+ ctx.fillStyle = background; ctx.fillRect(0,0,FIELD_WIDTH,FIELD_HEIGHT);
22
+ const rect = (x,y,w,h,color=foreground) => {ctx.fillStyle=color;ctx.fillRect(Math.round(x),Math.round(y),w,h);};
23
+ const pose = direction => frames[{left:'lookLeft',right:'lookRight',up:'lookUp'}[direction]] || idle;
24
+ const pet = (frame, x, bottom, {rival=false,fallen=false}={}) => {
25
+ const width = fallen ? frame.length : Math.max(...frame.map(row=>row.length));
26
+ const height = fallen ? Math.max(...frame.map(row=>row.length)) : frame.length;
27
+ const left=Math.max(0,Math.min(FIELD_WIDTH-width,Math.round(x)));
28
+ const top=Math.max(0,Math.min(FIELD_HEIGHT-height,Math.round(bottom-height)));
29
+ for(let row=0;row<frame.length;row++)for(let col=0;col<frame[row].length;col++){
30
+ const pixel=frame[row][col];if(!pixel)continue;
31
+ rect(left+(fallen?frame.length-1-row:col),top+(fallen?col:row),1,1,rival?(opponent?.palette[pixel]||foreground):palette[pixel]||foreground);
32
+ }
33
+ };
34
+ const centered = (frame,bottom=24) => pet(frame,(FIELD_WIDTH-frame[0].length)/2,bottom);
35
+ const arrow = (direction,x,y,color=foreground) => {for(const [dx,dy] of arrowPixels[direction]||[])rect(x+dx,y+dy,1,1,color);};
36
+ const reaction = (x,y,good) => {
37
+ if(good){rect(x+1,y,1,1);rect(x,y+1,3,1);rect(x+1,y+2,1,1);}
38
+ else {rect(x+1,y,1,2);rect(x+1,y+3,1,1);}
39
+ };
40
+
41
+ if(s.id==='get'){
42
+ const frame=s.reaction==='happy'?frames.happy||idle:s.reaction==='startled'?frames.sad||idle:idle;
43
+ pet(frame,s.lane*10+5-frame[0].length/2,24);
44
+ if(s.object){
45
+ const occupiedTop=idle.findIndex(row=>row.some(Boolean));
46
+ const catchY=FIELD_HEIGHT-idle.length+Math.max(0,occupiedTop)-2;
47
+ const x=s.object.lane*10+4,y=s.object.y/10*catchY;
48
+ if(s.object.kind==='note'){rect(x+2,y,1,3);rect(x,y+2,3,1);}
49
+ else {rect(x+1,y,1,1,'#825b41');rect(x,y+1,3,2,'#825b41');}
50
+ }
51
+ }
52
+ if(s.id==='heading'){
53
+ const geometry=headingPose(idle,s.x,s.jump);
54
+ pet(idle,geometry.left,geometry.top+idle.length);
55
+ rect(s.ball.x,s.ball.y,2,2);
56
+ }
57
+ if(s.id==='bump'){
58
+ const reveal=s.phase==='reveal',time=s.phaseTime||0;
59
+ const contact=reveal&&time>=250&&time<1000;
60
+ const fallen=contact&&!reducedMotion&&time>=400;
61
+ const playerLoses=s.boutWon===false;
62
+ const player=frames.lookRight||idle;
63
+ pet(player,contact&&!playerLoses?5:1,24,{fallen:fallen&&playerLoses});
64
+ pet(rivalFrames.lookLeft||rivalFrames.idle1,contact&&playerLoses?17:21,24,{rival:true,fallen:fallen&&!playerLoses});
65
+ if(reveal)reaction(playerLoses?6:25,5,false);
66
+ }
67
+ if(s.id==='memory'){
68
+ const direction=s.phase==='watch'?s.cue:s.lastInput;
69
+ centered(pose(direction));
70
+ if(direction)arrow(direction,14,3);
71
+ if(s.result)reaction(14,3,s.result==='win');
72
+ }
73
+ if(s.id==='flag'){
74
+ centered(pose(s.response));
75
+ const flag=(left,raised)=>{
76
+ const x=left?6:25,y=raised?10:18;
77
+ rect(x,y,1,5);rect(left?x-3:x+1,y,3,2,left?'#365f54':'#934d42');
78
+ };
79
+ flag(true,s.response==='left'||s.response==='both');
80
+ flag(false,s.response==='right'||s.response==='both');
81
+ if(s.phase==='cue'){
82
+ if(s.cue==='fake'){
83
+ for(let i=0;i<5;i++){rect(14+i,2+i,1,1);rect(18-i,2+i,1,1);}
84
+ }else if(s.cue==='both'){arrow('left',8,2);arrow('right',19,2);}
85
+ else arrow(s.cue,14,2);
86
+ }
87
+ if(s.phase==='rest')reaction(14,3,s.correct);
88
+ }
89
+ q('.arcade-meter').hidden=!['bump','hoops'].includes(s.id);
90
+ if(['bump','hoops'].includes(s.id)){
91
+ const target=s.id==='bump'?s.target:[s.target.min*100,s.target.max*100];
92
+ q('.arcade-meter span').style.cssText=`left:${target[0]}%;width:${target[1]-target[0]}%`;
93
+ q('.arcade-meter i').style.left=`${s.id==='bump'?s.meter:s.marker*100}%`;
94
+ }
95
+ if(s.id==='sprint'){
96
+ for(let y=0;y<FIELD_HEIGHT;y++)rect(31,y,1,1,y%2?background:foreground);
97
+ const player=frames.lookRight||idle;
98
+ const end=30-player[0].length;
99
+ pet(player,Math.min(end,s.progress/s.finish*end),12);
100
+ const rival=rivalFrames.lookRight||rivalFrames.idle1;
101
+ pet(rival,Math.min(30-rival[0].length,s.opponent/s.finish*(30-rival[0].length)),24,{rival:true});
102
+ if(s.phase==='countdown'||(s.phase==='race'&&s.elapsed<1000)){
103
+ const active=s.phase==='race'?2:s.countdown>1500?0:1;
104
+ rect(23,1,5,11,'#17282e');
105
+ for(let i=0;i<3;i++)rect(24,2+i*3,3,2,i===active?['#ef7971','#f5d56e','#83da9c'][i]:'#455459');
106
+ }
107
+ }
108
+ if(s.id==='hoops'){
109
+ const frame=s.phase==='arc'?frames.eat||idle:frames.lookRight||idle;
110
+ pet(frame,0,24);
111
+ rect(30,3,1,20);rect(29,3,1,4);rect(27,23,5,1);
112
+ rect(24,9,7,1,'#ad5a35');
113
+ rect(25,10,1,2);rect(29,10,1,2);rect(26,12,1,2);rect(28,12,1,2);rect(26,13,3,1);
114
+ if(s.ball){rect(s.ball.x,s.ball.y,2,2,'#f6a64b');rect(s.ball.x,s.ball.y+1,1,1,'#8a4726');}
115
+ }
116
+ }
@@ -0,0 +1,64 @@
1
+ // Compact playfield portraits are drawn separately from the care screen art.
2
+ // Each is eight pixels wide and six tall, leaving room for notes and balls.
3
+ const rows={
4
+ classic:{
5
+ babytchi:['00111100','01000010','10100101','10011001','01111110','00011000'],
6
+ marutchi:['00111100','01111110','11011011','11111111','11000011','01111110'],
7
+ tamatchi:['00011000','00111100','01011010','01111110','00100100','01000010'],
8
+ kuchitamatchi:['00111100','01000010','10100101','01111110','00111100','01000010'],
9
+ mametchi:['01000100','10111010','10000010','01010100','01111100','01000100'],
10
+ ginjirotchi:['00111100','01111110','10111011','11000011','01111110','01000010'],
11
+ maskutchi:['00111100','01000010','11011011','10100101','01111110','01000010'],
12
+ kuchipatchi:['00111100','01000010','10100101','10000111','01111110','01000010'],
13
+ nyorotchi:['01111000','10000100','10101100','10010100','01111000','00111110'],
14
+ tarakotchi:['01000010','00111100','01000010','10100101','10111101','01000010'],
15
+ oyajitchi:['00011000','00111100','01011010','11111111','01011010','00100100'],
16
+ },
17
+ pizza:{
18
+ babytchi:['00055000','00511500','05111150','05155150','00511500','00055000'],
19
+ marutchi:['00555500','05244250','52333325','52355325','05222250','00500500'],
20
+ tamatchi:['05555550','52222225','05355350','00533500','00055000','00500500'],
21
+ kuchitamatchi:['00555500','05222250','53355350','05333250','00533250','00055000'],
22
+ mametchi:['05555550','52222225','05155150','00546500','00035000','00500500'],
23
+ ginjirotchi:['05555550','52222225','05455450','00533500','00045000','00500500'],
24
+ maskutchi:['05555550','52222225','05755750','005AB500','000B5000','00500500'],
25
+ kuchipatchi:['05555550','52222225','53455435','53BB3B35','05222250','00500500'],
26
+ nyorotchi:['05555550','52222225','05355350','00599500','00035000','00500500'],
27
+ tarakotchi:['05555550','52222225','05855850','005C8500','000C5000','00500500'],
28
+ oyajitchi:['05555550','52222225','05155150','00566500','00065000','00500500'],
29
+ },
30
+ cyberpunk:{
31
+ babytchi:['00333300','03222300','03121300','03222300','00333300','00303000'],
32
+ marutchi:['00040000','00333300','03222330','03121230','00333300','00303000'],
33
+ tamatchi:['00333300','03222330','03333330','03121330','00344300','03000300'],
34
+ kuchitamatchi:['00400300','00333300','03121340','03222300','00344300','03000300'],
35
+ mametchi:['00700700','00333330','03122130','33377333','03222300','03303300'],
36
+ ginjirotchi:['00300300','00333330','03322330','33314333','03442300','03303300'],
37
+ maskutchi:['00600600','00666660','06122160','66611666','06216300','06606600'],
38
+ kuchipatchi:['04400000','00333330','03122130','34344343','03222300','03303300'],
39
+ nyorotchi:['03300400','03233330','04312133','03344330','00322330','03300303'],
40
+ tarakotchi:['05555550','59999995','59199195','59911995','05555550','00333000'],
41
+ oyajitchi:['00400300','00333340','43121330','00344300','04322304','03300330'],
42
+ },
43
+ lilgotchi:{
44
+ babytchi:['00000000','00022000','00211200','02133120','00211200','00022000'],
45
+ marutchi:['00022000','00211200','02111120','02133120','02111120','00222200'],
46
+ tamatchi:['00022000','00281200','02111120','02133120','02111120','00222200'],
47
+ kuchitamatchi:['00022000','00265200','02555520','02533520','02655620','00222200'],
48
+ mametchi:['00022000','00284200','02444420','02433420','02444120','00222200'],
49
+ ginjirotchi:['00066000','00656600','06555560','06533560','00665600','00066000'],
50
+ maskutchi:['00022000','00288200','02888820','02833820','02888820','00222200'],
51
+ kuchipatchi:['00222200','02111120','21111112','21133112','21111112','02222220'],
52
+ nyorotchi:['00066000','00682600','06222260','06266260','06222260','00666600'],
53
+ tarakotchi:['00066000','00647600','06777760','06788760','06777360','00666600'],
54
+ oyajitchi:['00022000','00288000','02883000','02833820','02888320','00222200'],
55
+ },
56
+ };
57
+ export const arcadeSprites=Object.freeze(Object.fromEntries(Object.entries(rows).map(([family,pets])=>[
58
+ family,Object.freeze(Object.fromEntries(Object.entries(pets).map(([id,drawing])=>[
59
+ id,Object.freeze(drawing.map(row=>Object.freeze([...row].map(pixel=>parseInt(pixel,16)))))
60
+ ])))
61
+ ])));
62
+ export function arcadeSprite(family,identity){
63
+ return arcadeSprites[family]?.[identity]||arcadeSprites.classic[identity]||arcadeSprites.classic.babytchi;
64
+ }
@@ -0,0 +1,393 @@
1
+ import { chooseOpponent } from './arcade-opponents.js';
2
+ import { drawArcade } from "./arcade-renderer.js";
3
+ import { games } from "./arcade-catalog.js";
4
+ import { arcadeSprite } from "./arcade-sprites.js";
5
+ import { petType } from "./storage.js";
6
+ import { SP, palette } from "./skins.js";
7
+ import { applyWellbeingAction } from "./wellbeing.js";
8
+ import { now } from "./clock.js";
9
+ export class Arcade {
10
+ constructor(app) {
11
+ this.app = app;
12
+ this.model = null;
13
+ this.selection = null;
14
+ this.settled = false;
15
+ this.last = 0;
16
+ this.held = null;
17
+ this.repeatAt = 0;
18
+ this.root = document.createElement("div");
19
+ this.root.className = "overlay";
20
+ this.root.id = "arcade-overlay";
21
+ this.root.tabIndex = -1;
22
+ this.root.setAttribute("role", "dialog");
23
+ this.root.setAttribute("aria-modal", "true");
24
+ this.root.setAttribute("aria-labelledby", "arcade-title");
25
+ this.root.innerHTML =
26
+ '<h2 class="overlay-title" id="arcade-title">Play</h2><div class="arcade-picker"></div><p class="arcade-instruction"></p><label class="arcade-options"><input type="checkbox"><span> Slower cues</span></label><canvas class="arcade-pet" aria-label="Your pet"></canvas><div class="arcade-cue" aria-live="polite"></div><canvas class="arcade-field" width="32" height="24" aria-label="Game field"></canvas><div class="arcade-meter"><span></span><i></i></div><p class="arcade-score" aria-live="off"></p><span class="arcade-announcement" style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)" role="status" aria-atomic="true"></span><button class="overlay-btn arcade-start">Start</button><div class="arcade-actions"></div><button class="overlay-btn arcade-pause">Pause</button><button class="overlay-close arcade-exit">Back to games</button>';
27
+ document.body.append(this.root);
28
+ this.q = (selector) => this.root.querySelector(selector);
29
+ this.q(".arcade-start").onclick = () => this.start();
30
+ this.q(".arcade-pause").onclick = () => this.togglePause();
31
+ this.q(".arcade-exit").onclick = () => {
32
+ if (this.selection) this.open();
33
+ else app.closeOverlay("arcade-overlay");
34
+ };
35
+ document.addEventListener("keydown", (event) => {
36
+ if (
37
+ !this.root.classList.contains("show") ||
38
+ event.repeat ||
39
+ !this.model ||
40
+ ["INPUT", "SELECT", "TEXTAREA"].includes(event.target.tagName) ||
41
+ (event.target.tagName === "BUTTON" &&
42
+ [" ", "Enter"].includes(event.key))
43
+ )
44
+ return;
45
+ const action = {
46
+ ArrowLeft: "left",
47
+ ArrowRight: "right",
48
+ ArrowUp: this.selection === "heading" ? "jump" : "up",
49
+ " ":
50
+ games[this.selection].actions.length === 1
51
+ ? games[this.selection].actions[0]
52
+ : this.selection === "flag"
53
+ ? "both"
54
+ : "jump",
55
+ }[event.key];
56
+ if (action) {
57
+ event.preventDefault();
58
+ if (
59
+ ["get", "heading"].includes(this.selection) &&
60
+ ["left", "right"].includes(action)
61
+ ) {
62
+ this.held = action;
63
+ this.repeatAt = 0;
64
+ }
65
+ this.input(action);
66
+ }
67
+ });
68
+ document.addEventListener("keyup", (event) => {
69
+ if (["ArrowLeft", "ArrowRight"].includes(event.key)) this.held = null;
70
+ });
71
+ window.addEventListener("blur", () => {
72
+ this.held = null;
73
+ this.pause();
74
+ });
75
+ const loop = (time) => {
76
+ if (this.model) {
77
+ if(this.petChanged()){this.last=time;requestAnimationFrame(loop);return;}
78
+ if (app.state.isDead || app.state.vacationAt > 0) {
79
+ this.cancel();
80
+ this.last = time;
81
+ requestAnimationFrame(loop);
82
+ return;
83
+ }
84
+ if (document.hidden || app.panelVisible === false) this.pause();
85
+ else if (!this.model.snapshot().paused) {
86
+ if (this.last) this.model.advance(time - this.last);
87
+ if (this.held && time >= this.repeatAt) {
88
+ this.input(this.held);
89
+ this.repeatAt = time + 150;
90
+ }
91
+ this.paint();
92
+ }
93
+ }
94
+ this.last = time;
95
+ requestAnimationFrame(loop);
96
+ };
97
+ requestAnimationFrame(loop);
98
+ }
99
+ open() {
100
+ this.cancel();
101
+ this.q(".arcade-announcement").textContent = "";
102
+ this.announcementKey=null;
103
+ this.selection = null;
104
+ this.app.openOverlay("arcade-overlay");
105
+ this.q(".overlay-title").textContent = "Choose a game";
106
+ this.q(".arcade-picker").replaceChildren();
107
+ for (const [id, name] of [
108
+ ["guess", "Left and right"],
109
+ ...Object.entries(games).map(([id, g]) => [id, g.name]),
110
+ ]) {
111
+ const b = document.createElement("button");
112
+ b.className = "overlay-btn";
113
+ b.textContent = name;
114
+ b.onclick = () => {
115
+ if (id === "guess") {
116
+ const reason=this.unavailable();
117
+ if(reason){this.q(".arcade-instruction").textContent=reason;this.q(".arcade-announcement").textContent=reason;return;}
118
+ this.app.closeOverlay("arcade-overlay");
119
+ this.app.startGame();
120
+ } else this.select(id);
121
+ };
122
+ this.q(".arcade-picker").append(b);
123
+ }
124
+ this.showRun(false);
125
+ this.q(".arcade-instruction").hidden = false;
126
+ this.q(".arcade-instruction").textContent = this.unavailable() || "Pick something to play.";
127
+ this.q(".arcade-exit").textContent = "Close";
128
+ this.q(".arcade-picker button")?.focus();
129
+ }
130
+ showRun(selected) {
131
+ for (const selector of [
132
+ ".arcade-options",
133
+ ".arcade-start",
134
+ ".arcade-pet",
135
+ ".arcade-cue",
136
+ ".arcade-field",
137
+ ".arcade-score",
138
+ ".arcade-meter",
139
+ ".arcade-actions",
140
+ ".arcade-pause",
141
+ ])
142
+ this.q(selector).hidden = !selected;
143
+ this.q(".arcade-picker").hidden = selected;
144
+ }
145
+ select(id) {
146
+ this.selection = id;
147
+ this.q(".arcade-announcement").textContent = "";
148
+ this.announcementKey=null;
149
+ this.q(".arcade-options span").textContent =
150
+ id === "sprint" ? " Steady running" : ["bump","heading"].includes(id) ? " Slower pace" : " Slower cues";
151
+ this.showRun(true);
152
+ this.q(".overlay-title").textContent = games[id].name;
153
+ this.q(".arcade-instruction").hidden = false;
154
+ this.q(".arcade-instruction").textContent = games[id].instruction + (id === "sprint" ? " You: upper lane. Opponent: lower lane." : "");
155
+ this.q(".arcade-start").textContent = "Start";
156
+ this.q(".arcade-start").disabled = !!this.unavailable();
157
+ this.q(".arcade-start").hidden = false;
158
+ this.q(".arcade-options").hidden = ![
159
+ "memory",
160
+ "bump",
161
+ "heading",
162
+ "flag",
163
+ "sprint",
164
+ "hoops",
165
+ ].includes(id);
166
+ this.q(".arcade-field").hidden = true;
167
+ this.q(".arcade-meter").hidden = true;
168
+ this.q(".arcade-pause").hidden = true;
169
+ this.q(".arcade-cue").textContent = "";
170
+ this.q(".arcade-score").textContent = "";
171
+ this.q(".arcade-actions").replaceChildren();
172
+ this.q(".arcade-exit").textContent = "Back to games";
173
+ this.drawPet();
174
+ if (this.unavailable()) this.q(".arcade-instruction").textContent = this.unavailable();
175
+ this.q(this.unavailable()?".arcade-exit":".arcade-start").focus();
176
+ }
177
+ unavailable() {
178
+ const st=this.app.state;
179
+ if(st.isDead)return "Start a new pet before playing.";
180
+ if(st.vacationAt>0)return "Turn Vacation off before playing.";
181
+ if(st.stage==="egg")return "Your pet can play after hatching.";
182
+ if(st.isSleeping)return "Wake your pet before playing.";
183
+ if(st.isSick)return "Your pet needs medicine before playing.";
184
+ if(st.hunger===0)return "Feed your pet before playing.";
185
+ return "";
186
+ }
187
+ start() {
188
+ const st = this.app.state;
189
+ if ((this.model && !this.model.snapshot().result) || !this.selection) return;
190
+ const reason=this.unavailable();
191
+ if(reason){
192
+ this.q(".arcade-instruction").textContent=reason;
193
+ this.q(".arcade-announcement").textContent=reason;
194
+ return;
195
+ }
196
+ this.cueSoundKey=null;
197
+ this.announcementKey=null;
198
+ this.runCharacter = st.character;
199
+ this.opponent = ['bump','sprint'].includes(this.selection) ? chooseOpponent(petType) : null;
200
+ this.model = games[this.selection].create({
201
+ sprite: SP[st.character]?.idle1,
202
+ slow: this.q("input").checked,
203
+ hold: this.q("input").checked,
204
+ });
205
+ this.settled = false;
206
+ this.last = 0;
207
+ applyWellbeingAction(st, "play", now());
208
+ st.save();
209
+ this.q(".arcade-start").hidden = true;
210
+ this.q(".arcade-instruction").hidden = true;
211
+ this.q(".arcade-options").hidden = true;
212
+ this.q(".arcade-field").hidden = false;
213
+ this.q(".arcade-pet").hidden = true;
214
+ this.q(".arcade-pause").hidden = false;
215
+ this.q(".arcade-actions").hidden = false;
216
+ this.q(".arcade-actions").replaceChildren();
217
+ for (const action of games[this.selection].actions) {
218
+ const b = document.createElement("button");
219
+ b.className = "overlay-btn";
220
+ b.textContent =
221
+ action === "run" && this.q("input").checked
222
+ ? "Run steadily"
223
+ : action[0].toUpperCase() + action.slice(1);
224
+ b.onclick = () => this.input(action);
225
+ if (
226
+ ["get", "heading"].includes(this.selection) &&
227
+ ["left", "right"].includes(action)
228
+ ) {
229
+ b.onpointerdown = () => {
230
+ this.held = action;
231
+ this.repeatAt = 0;
232
+ };
233
+ for (const type of ["pointerup", "pointercancel", "pointerleave"])
234
+ b.addEventListener(type, () => (this.held = null));
235
+ }
236
+ this.q(".arcade-actions").append(b);
237
+ }
238
+ this.paint();
239
+ this.root.focus();
240
+ }
241
+ input(action) {
242
+ if(this.petChanged())return;
243
+ if (this.app.state.isDead || this.app.state.vacationAt > 0) {
244
+ this.cancel();
245
+ return;
246
+ }
247
+ if (!this.model) return;
248
+ if(document.hidden || this.app.panelVisible===false){this.pause();return;}
249
+ const s = this.model.snapshot();
250
+ if(s.paused || s.result)return;
251
+ if (this.selection === "sprint" && s.hold && action === "run")
252
+ action = "hold";
253
+ this.model.input(action);
254
+ this.paint();
255
+ }
256
+ pause() {
257
+ this.held = null;
258
+ this.cueSoundKey=null;
259
+ if (this.model && !this.model.snapshot().result) {
260
+ this.model.pause();
261
+ this.q(".arcade-pause").textContent = "Resume";
262
+ this.q(".arcade-cue").textContent = "Paused";
263
+ }
264
+ }
265
+ togglePause() {
266
+ if (!this.model) return;
267
+ if(this.app.state.isDead || this.app.state.vacationAt>0){this.cancel();return;}
268
+ if(document.hidden || this.app.panelVisible===false)return;
269
+ if (this.model.snapshot().paused) {
270
+ this.model.resume();
271
+ this.last = 0;
272
+ this.q(".arcade-pause").textContent = "Pause";
273
+ this.paint();
274
+ } else this.pause();
275
+ }
276
+ petChanged() {
277
+ if(!this.model||!this.runCharacter||this.runCharacter===this.app.state.character)return false;
278
+ this.open();
279
+ this.q(".arcade-instruction").textContent="Your pet grew! Pick a game for your new look.";
280
+ return true;
281
+ }
282
+ cancel() {
283
+ this.runCharacter = null;
284
+ this.held = null;
285
+ this.model?.quit();
286
+ this.model = null;
287
+ this.last = 0;
288
+ }
289
+ drawPet() {
290
+ const sp = SP[this.app.state.character];
291
+ if (sp) this.app.renderer.renderToCanvas(this.q(".arcade-pet"), sp.idle1);
292
+ }
293
+ paint() {
294
+ if(this.petChanged())return;
295
+ const s = this.model.snapshot();
296
+ if(s.id === "sprint" && s.hold){
297
+ const run=this.q(".arcade-actions").children[0];
298
+ if(run){run.textContent=s.holding?"Stop running":"Run steadily";run.setAttribute("aria-pressed",String(s.holding));}
299
+ }
300
+ this.drawPet();
301
+ const accepting=!s.paused&&!s.result&&(s.id!=="memory"||s.phase==="answer")&&(s.id!=="flag"||(s.phase==="cue"&&s.response===null))&&(s.id!=="hoops"||["ready","aim"].includes(s.phase))&&(s.id!=="bump"||s.phase==="meter")&&(s.id!=="sprint"||s.phase==="race");
302
+ for(const button of this.q(".arcade-actions").children)button.disabled=!accepting;
303
+ this.q(".arcade-pause").textContent = s.paused ? "Resume" : "Pause";
304
+ const score = s.result
305
+ ? s.result === "win"
306
+ ? "You won! +1 Happy"
307
+ : "Round finished. Try again."
308
+ : s.id === "get"
309
+ ? `${s.score}/12 notes · ${s.lives} lives · ${Math.ceil(s.remaining / 1000)}s`
310
+ : s.id === "bump"
311
+ ? `Bout ${s.bout}/3 · ${s.wins} wins · Target ${s.target.join(" to ")}`
312
+ : s.id === "heading"
313
+ ? `${s.score}/10 headers · ${s.misses}/3 misses · ${Math.ceil(s.remaining / 1000)}s`
314
+ : s.id === "memory"
315
+ ? `Round ${s.round}/5 · ${s.entered}/${s.length}`
316
+ : s.id === "flag"
317
+ ? `Round ${s.round}/15 · ${s.score} correct`
318
+ : s.id === "sprint"
319
+ ? `Race to ${s.finish} steps`
320
+ : `Shot ${s.shot}/5 · ${s.score} baskets`;
321
+ if (this.q(".arcade-score").textContent !== score)
322
+ this.q(".arcade-score").textContent = score;
323
+ const cue = arcadeCue(s);
324
+ if (this.q(".arcade-cue").textContent !== cue)
325
+ this.q(".arcade-cue").textContent = cue;
326
+ // Announce outcomes and care changes, not the clock on every second.
327
+ const announcementKey=JSON.stringify([s.result,s.score,s.lives,s.misses,s.round,s.bout,s.shot]);
328
+ if(announcementKey!==this.announcementKey){
329
+ this.announcementKey=announcementKey;
330
+ this.q(".arcade-announcement").textContent=s.id==="get"&&!s.result
331
+ ? `${s.score} notes. ${s.lives} lives.`
332
+ : s.id==="heading"&&!s.result
333
+ ? `${s.score} headers. ${s.misses} misses.` : score;
334
+ }
335
+ if(s.id==="memory" && s.phase==="watch" && s.cue && !s.paused){
336
+ const key=`${s.round}:${s.cueIndex}`;
337
+ if(this.cueSoundKey!==key){
338
+ this.cueSoundKey=key;
339
+ this.app.sound.beep?.({left:440,up:660,right:880}[s.cue],100);
340
+ }
341
+ }
342
+ drawArcade(
343
+ this.q,
344
+ s,
345
+ arcadeSprite(petType, this.app.state.character),
346
+ palette,
347
+ {opponent:this.opponent, reducedMotion:!!this.app.motionDisabled?.(), frames:SP[this.app.state.character], foreground:this.app.renderer.fg, background:this.app.renderer.bg},
348
+ );
349
+ if(s.result)this.q(".arcade-meter").hidden = true;
350
+ if (
351
+ s.result &&
352
+ !this.settled &&
353
+ !this.app.state.isDead &&
354
+ !(this.app.state.vacationAt > 0)
355
+ ) {
356
+ this.settled = true;
357
+ if (s.result === "win") {
358
+ this.app.state.happiness = Math.min(4, this.app.state.happiness + 1);
359
+ this.app.sound.gameWin();
360
+ } else this.app.sound.gameLose();
361
+ this.app.state.save();
362
+ this.app.updateButtons();
363
+ this.q(".arcade-pause").hidden = true;
364
+ this.held = null;
365
+ this.q(".arcade-actions").hidden = true;
366
+ this.q(".arcade-meter").hidden = true;
367
+ this.q(".arcade-start").textContent = "Play again";
368
+ this.q(".arcade-start").hidden = false;
369
+ this.q(".arcade-start").disabled = !!this.unavailable();
370
+ this.q(".arcade-start").focus();
371
+ for (const b of this.q(".arcade-actions").children) b.disabled = true;
372
+ }
373
+ }
374
+ }
375
+
376
+ export function arcadeCue(s) {
377
+ if(s.paused)return "Paused";
378
+ if(s.result)return s.result==="win"?"Nice work!":"Try another round";
379
+ if(s.id==="memory"&&s.phase==="between")return "Nice! Next round…";
380
+ if(s.id==="memory")return s.cue?`Watch: ${{left:"← Left",up:"↑ Up",right:"→ Right"}[s.cue]}`:s.phase==="answer"?"Your turn":"Watch";
381
+ if(s.id==="flag"){
382
+ if(s.phase==="prepare")return "Get ready for the next flag";
383
+ if(s.phase==="rest")return s.correct?"Correct!":"Missed that one";
384
+ return `Round ${s.round}: ${s.cue==="fake"?"Fake! Wait":s.cue?.toUpperCase()||"Wait"}`;
385
+ }
386
+ if(s.phase==="countdown")return s.id==="sprint"?"Wait for green":"Ready…";
387
+ if(s.id==="bump")return s.phase==="meter"?"Stop in the target":s.boutWon?"You won the bout!":"Opponent wins this bout";
388
+ if(s.id==="hoops")return s.phase==="ready"?"Press Shoot when ready":s.phase==="aim"?"Stop in the target":s.arcProgress<0.72?"In the air…":s.made?"Basket!":"Missed the basket";
389
+ if(s.id==="get")return "Catch notes. Dodge poop.";
390
+ if(s.id==="heading")return "Jump to meet the ball";
391
+ if(s.id==="sprint")return s.hold?(s.holding?"Running steadily":"Press Run steadily to go"):"Keep running!";
392
+ return "";
393
+ }
@@ -0,0 +1,20 @@
1
+ // Pause every relative game clock together so a holiday cannot age a pet.
2
+ const clocks=['stageStartTime','lastProcessed','lastHungerDecay','lastHappyDecay','lastPoopTime','lastAgeInc','snackWindowStart','attentionCallTime','wellbeingUpdatedAt'];
3
+ export const MAX_CATCHUP=8*60*60*1000;
4
+ export function shiftClocks(state,elapsed) {
5
+ if(!Number.isFinite(elapsed)||elapsed===0)return;
6
+ for(const key of clocks)if(Number.isFinite(state[key]))state[key]=Math.max(0,state[key]+elapsed);
7
+ if(state.sleepOverrideUntil>0)state.sleepOverrideUntil=Math.max(0,state.sleepOverrideUntil+elapsed);
8
+ }
9
+ export function resumeVacation(state,now) {
10
+ if(!Number.isFinite(state.vacationAt)||state.vacationAt<=0||!Number.isFinite(now)||now<0)return false;
11
+ // Rebase together even after a system clock correction, retaining elapsed phases.
12
+ shiftClocks(state,now-state.vacationAt);
13
+ state.vacationAt=0;
14
+ return true;
15
+ }
16
+ export function limitAbsence(state,now) {
17
+ const skipped=Math.max(0,now-state.lastProcessed-MAX_CATCHUP);
18
+ shiftClocks(state,skipped);
19
+ return skipped;
20
+ }