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,62 @@
1
+ const palette=['','#fff0cc','#e7c88d','#435345','#c7dfcf','#dac9a5','#735443','#638b82','#fffbea'];
2
+ const expressions=['idle1','idle2','happy','sad','eat','sleep','lookLeft','lookRight','lookUp'];
3
+ const designs=[
4
+ {id:'egg',name:'Little Egg',stage:'egg',widths:[2,4,6,6,4],fill:1,edge:2},
5
+ {id:'babytchi',name:'Peep Egg',stage:'baby',widths:[2,4,6,8,8,6,4],fill:1,edge:2,face:8},
6
+ {id:'marutchi',name:'Growing Egg',stage:'child',widths:[2,4,6,8,8,10,10,8,6],fill:1,edge:2,face:6},
7
+ {id:'tamatchi',name:'Smooth Egg',stage:'teen',widths:[2,4,6,8,8,10,10,10,8,6],fill:1,edge:2,face:6,marks:[[4,3,8],[3,5,8]]},
8
+ {id:'kuchitamatchi',name:'Speckled Egg',stage:'teen',widths:[2,4,6,8,8,10,10,10,8,6],fill:5,edge:2,face:6,marks:[[5,3,6],[3,5,6],[8,5,6],[3,8,6],[7,10,6]]},
9
+ {id:'mametchi',name:'Duck Egg',stage:'adult',widths:[2,4,6,8,8,10,10,10,10,8,6],fill:4,edge:2,face:6,marks:[[4,3,8],[3,5,8],[8,9,1]]},
10
+ {id:'ginjirotchi',name:'Quail Egg',stage:'adult',widths:[2,4,6,8,8,8,6,4],fill:5,edge:6,face:7,marks:[[5,5,6],[3,7,6],[8,7,6],[4,9,6],[7,10,6]]},
11
+ {id:'maskutchi',name:'Goose Egg',stage:'adult',widths:[2,4,6,6,8,8,8,10,10,10,8,6],fill:8,edge:2,face:6,marks:[[4,3,1],[3,7,1],[8,9,1]]},
12
+ {id:'kuchipatchi',name:'Jumbo Egg',stage:'adult',widths:[4,6,8,10,10,12,12,12,12,12,10,8],fill:1,edge:2,face:6,marks:[[3,3,8],[2,5,8],[2,6,8],[9,9,2]]},
13
+ {id:'nyorotchi',name:'Golden Egg',stage:'adult',widths:[2,4,6,8,8,10,10,10,8,6],fill:2,edge:6,face:6,ink:6,marks:[[5,3,8],[4,4,8],[3,5,8],[3,6,1],[8,9,1]]},
14
+ {id:'tarakotchi',name:'Century Egg',stage:'adult',widths:[2,4,6,8,10,10,10,10,8,6],fill:7,edge:6,face:6,ink:8,marks:[[5,3,4],[4,4,4],[3,5,4],[8,9,3]]},
15
+ {id:'oyajitchi',name:'Cracked Elder',stage:'adult',widths:[2,4,6,6,8,8,8,10,10,10,8,6],fill:8,edge:2,face:6,marks:[[5,1,3],[6,2,3],[5,3,3],[6,4,3],[4,3,1],[3,7,1]]},
16
+ ];
17
+ function draw(design,expression){
18
+ const frame=Array.from({length:12},()=>Array(12).fill(0));
19
+ const top=12-design.widths.length;
20
+ design.widths.forEach((width,row)=>{
21
+ const left=(12-width)/2;
22
+ for(let x=left;x<left+width;x++)frame[top+row][x]=row===design.widths.length-1||x===left||x===left+width-1?design.edge:design.fill;
23
+ });
24
+ for(const [x,y,color] of design.marks||[])if(frame[y][x])frame[y][x]=color;
25
+ if(design.stage==='egg'){
26
+ if(expression==='idle2'){frame[8][5]=8;frame[8][6]=8;}
27
+ return frame;
28
+ }
29
+ const y=design.face-(expression==='lookUp'?1:0),ink=design.ink||3;
30
+ const pixel=(x,dy,color=ink)=>{if(frame[y+dy]?.[x])frame[y+dy][x]=color;};
31
+ if(expression==='lookLeft'||expression==='lookRight'){
32
+ const left=expression==='lookLeft';
33
+ pixel(left?3:6,0);pixel(left?5:8,0);
34
+ pixel(left?2:9,1);pixel(left?3:8,2);
35
+ }else if(expression==='happy'){
36
+ pixel(4,0);pixel(3,1);pixel(5,1);pixel(7,0);pixel(6,1);pixel(8,1);pixel(5,2);pixel(6,2);
37
+ }else if(expression==='sleep'){
38
+ pixel(3,1);pixel(4,1);pixel(7,1);pixel(8,1);
39
+ }else if(expression==='sad'){
40
+ pixel(4,0);pixel(7,0);pixel(5,1);pixel(6,1);pixel(4,2);pixel(7,2);
41
+ }else{
42
+ pixel(4,0);pixel(7,0);
43
+ if(expression==='eat'){pixel(5,1);pixel(6,1);pixel(5,2);pixel(6,2);}
44
+ else if(expression==='idle2'){pixel(5,2);pixel(6,2);}
45
+ else pixel(5,2);
46
+ }
47
+ if(design.id==='oyajitchi'){
48
+ // A missing side shard opens into a long jagged crack beside the face.
49
+ for(const [x,y] of [[8,2],[8,3],[7,3],[8,4],[9,4],[8,5]])frame[y][x]=0;
50
+ for(const [x,y] of [[7,4],[7,5],[8,6],[9,7],[8,8],[9,9],[8,10]])frame[y][x]=3;
51
+ }
52
+ return frame;
53
+ }
54
+ const artwork={
55
+ type:'Lilgotchi',palette,
56
+ pets:designs.map(design=>({id:design.id,name:design.name,stage:design.stage,frames:Object.fromEntries((design.stage==='egg'?['idle1','idle2']:expressions).map(expression=>[expression,draw(design,expression)]))})),
57
+ };
58
+
59
+ const freezeFrame=frame=>Object.freeze(frame.map(row=>Object.freeze([...row])));
60
+ export const lilgotchiPalette=Object.freeze(artwork.palette);
61
+ export const lilgotchiNames=Object.freeze(Object.fromEntries(artwork.pets.map(pet=>[pet.id,pet.name])));
62
+ export const lilgotchiSprites=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,52 @@
1
+ import { model } from "./model.js";
2
+ export function createBump({ slow = false } = {}) {
3
+ const period = slow ? 3200 : 1600;
4
+ const s = {
5
+ id: "bump",
6
+ phase: "countdown",
7
+ result: null,
8
+ paused: false,
9
+ bout: 1,
10
+ wins: 0,
11
+ meter: 0,
12
+ target: [60, 85],
13
+ phaseTime: 0,
14
+ boutWon: null,
15
+ };
16
+ const settle = (win) => {
17
+ s.boutWon = win;
18
+ if (win) s.wins++;
19
+ s.phase = "reveal";
20
+ s.phaseTime = 0;
21
+ };
22
+ return model(
23
+ s,
24
+ (dt) => {
25
+ s.phaseTime += dt;
26
+ if (s.phase === "countdown" && s.phaseTime >= 1500) {
27
+ s.phase = "meter";
28
+ s.phaseTime = 0;
29
+ } else if (s.phase === "meter") {
30
+ const t = s.phaseTime % period;
31
+ s.meter = t <= period / 2 ? (t / period) * 200 : ((period - t) / period) * 200;
32
+ if (s.phaseTime >= 6000) settle(false);
33
+ } else if (s.phase === "reveal" && s.phaseTime >= 1200) {
34
+ if (s.bout === 3) {
35
+ s.result = s.wins >= 2 ? "win" : "loss";
36
+ s.phase = "done";
37
+ } else {
38
+ s.bout++;
39
+ s.target = [60 + (s.bout - 1) * 3, 85 - (s.bout - 1) * 3];
40
+ s.phase = "countdown";
41
+ s.phaseTime = 0;
42
+ s.meter = 0;
43
+ s.boutWon = null;
44
+ }
45
+ }
46
+ },
47
+ (action) => {
48
+ if (action === "bump" && s.phase === "meter")
49
+ settle(s.meter >= s.target[0] && s.meter <= s.target[1]);
50
+ },
51
+ );
52
+ }
@@ -0,0 +1,87 @@
1
+ // Flag cues are settled once, including fake cues where waiting is the answer.
2
+ export function createFlag({ random = Math.random, slow = false } = {}) {
3
+ const types = ["left", "right", "both", "fake"];
4
+ const duration = slow ? 4000 : 2400;
5
+ let phase = "prepare",
6
+ result = null,
7
+ paused = false,
8
+ remaining = 900;
9
+ let round = 1,
10
+ score = 0,
11
+ response = null,
12
+ correct = null;
13
+ let cue = types[Math.min(3, Math.max(0, Math.floor(random() * 4)))];
14
+ function settle() {
15
+ correct = cue === "fake" ? response === null : response === cue;
16
+ if (correct) score++;
17
+ phase = "rest";
18
+ remaining = 900;
19
+ }
20
+ return {
21
+ input(action) {
22
+ if (
23
+ result ||
24
+ paused ||
25
+ phase !== "cue" ||
26
+ response !== null ||
27
+ !["left", "right", "both"].includes(action)
28
+ )
29
+ return false;
30
+ response = action;
31
+ return true;
32
+ },
33
+ advance(ms) {
34
+ if (!Number.isFinite(ms) || ms <= 0 || result || paused) return;
35
+ while (ms > 0 && !result) {
36
+ const step = Math.min(ms, remaining);
37
+ ms -= step;
38
+ remaining -= step;
39
+ if (remaining > 0) break;
40
+ if (phase === "prepare") {
41
+ phase = "cue";
42
+ remaining = duration;
43
+ } else if (phase === "cue") settle();
44
+ else if (round === 15) {
45
+ phase = "done";
46
+ result = score >= 12 ? "win" : "loss";
47
+ } else {
48
+ round++;
49
+ phase = "cue";
50
+ remaining = duration;
51
+ response = null;
52
+ correct = null;
53
+ cue = types[Math.min(3, Math.max(0, Math.floor(random() * 4)))];
54
+ }
55
+ }
56
+ },
57
+ pause() {
58
+ if (!result) paused = true;
59
+ },
60
+ resume() {
61
+ paused = false;
62
+ },
63
+ quit() {
64
+ if (!result) {
65
+ result = "quit";
66
+ phase = "done";
67
+ }
68
+ },
69
+ snapshot() {
70
+ return {
71
+ id: "flag",
72
+ phase,
73
+ result,
74
+ paused,
75
+ cue,
76
+ response,
77
+ correct,
78
+ round,
79
+ rounds: 15,
80
+ score,
81
+ target: 12,
82
+ remaining,
83
+ duration,
84
+ };
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,59 @@
1
+ import { model, randomIndex, clamp } from "./model.js";
2
+ export function createGet({ random = Math.random } = {}) {
3
+ const s = {
4
+ id: "get",
5
+ phase: "playing",
6
+ result: null,
7
+ paused: false,
8
+ elapsed: 0,
9
+ remaining: 30000,
10
+ score: 0,
11
+ lives: 3,
12
+ lane: 1,
13
+ object: null,
14
+ reaction: null,
15
+ };
16
+ let wait = 300,
17
+ moveTime = -150;
18
+ return model(
19
+ s,
20
+ (dt) => {
21
+ s.elapsed += dt;
22
+ s.remaining = Math.max(0, 30000 - s.elapsed);
23
+ if (s.object) {
24
+ s.object.y +=
25
+ dt * (s.elapsed < 10000 ? 0.009 : s.elapsed < 20000 ? 0.011 : 0.013);
26
+ if (s.object.y >= 10) {
27
+ if (s.object.lane === s.lane) {
28
+ if (s.object.kind === "note") {
29
+ s.score++;
30
+ s.reaction = "happy";
31
+ } else {
32
+ s.lives--;
33
+ s.reaction = "startled";
34
+ }
35
+ }
36
+ s.object = null;
37
+ wait = 200;
38
+ }
39
+ } else if ((wait -= dt) <= 0) {
40
+ s.object = {
41
+ lane: randomIndex(random, 3),
42
+ kind: random() < 0.8 ? "note" : "poop",
43
+ y: 0,
44
+ };
45
+ s.reaction = null;
46
+ }
47
+ if (!s.lives || !s.remaining) {
48
+ s.result = s.lives > 0 && s.score >= 12 ? "win" : "loss";
49
+ s.phase = "done";
50
+ }
51
+ },
52
+ (action) => {
53
+ if (["left", "right"].includes(action) && s.elapsed - moveTime >= 150) {
54
+ s.lane = clamp(s.lane + (action === "left" ? -1 : 1), 0, 2);
55
+ moveTime = s.elapsed;
56
+ }
57
+ },
58
+ );
59
+ }
@@ -0,0 +1,117 @@
1
+ import { model, clamp } from "./model.js";
2
+ import { headingPose, FIELD_HEIGHT, FIELD_WIDTH } from "../arcade-geometry.js";
3
+
4
+ const defaultSprite = Array.from({ length: 12 }, () => Array(12).fill(1));
5
+
6
+ // Compare ball and head movement together so a rising jump cannot skip contact.
7
+ export function touchesHead(beforeBall, ball, beforeHead, head) {
8
+ return head.some((pixel, index) => {
9
+ const previous = beforeHead[index];
10
+ return (
11
+ ball.x + 2 > pixel.x && ball.x < pixel.x + 1 &&
12
+ beforeBall.y + 2 <= previous.y && ball.y + 2 >= pixel.y
13
+ );
14
+ });
15
+ }
16
+ export function createHeading({
17
+ random = Math.random,
18
+ slow = false,
19
+ sprite = defaultSprite,
20
+ } = {}) {
21
+ const halfWidth = Math.max(...sprite.map(row => row.length)) / 2;
22
+ const duration = slow ? 75000 : 45000;
23
+ const speed = slow ? 0.6 : 1;
24
+ const s = {
25
+ id: "heading",
26
+ phase: "countdown",
27
+ result: null,
28
+ paused: false,
29
+ elapsed: 0,
30
+ remaining: duration,
31
+ score: 0,
32
+ misses: 0,
33
+ x: 16,
34
+ jump: 0,
35
+ ball: { x: 16, y: 1 },
36
+ phaseTime: 0,
37
+ };
38
+ let jumpTime = -1,
39
+ velocity = 0,
40
+ moveTime = -100,
41
+ vx = 0.003;
42
+ const serve = () => {
43
+ s.ball = { x: clamp(s.x + (random() < 0.5 ? -3 : 3), 2, 29), y: 1 };
44
+ vx = random() < 0.5 ? -0.003 : 0.003;
45
+ velocity = 0.006 + Math.floor(s.score / 3) * 0.001;
46
+ s.phase = "playing";
47
+ };
48
+ return model(
49
+ s,
50
+ (dt) => {
51
+ const previousHead = headingPose(sprite, s.x, s.jump).head;
52
+ s.elapsed += dt;
53
+ s.remaining = Math.max(0, duration - s.elapsed);
54
+ dt *= speed;
55
+ s.phaseTime += dt;
56
+ if (jumpTime >= 0) {
57
+ jumpTime += dt;
58
+ s.jump = 3 * Math.sin(Math.PI * Math.min(1, jumpTime / 700));
59
+ if (jumpTime >= 700) {
60
+ jumpTime = -1;
61
+ s.jump = 0;
62
+ }
63
+ }
64
+ if (s.phase === "countdown" && s.phaseTime >= 1000) serve();
65
+ if (s.phase === "playing") {
66
+ const previous = { ...s.ball };
67
+ s.ball.y += velocity * dt;
68
+ s.ball.x += vx * dt;
69
+ if (s.ball.x < 1 || s.ball.x > 29) {
70
+ s.ball.x = clamp(s.ball.x, 1, 29);
71
+ vx = -vx;
72
+ }
73
+ velocity += dt * 0.000012;
74
+ if (s.ball.y < 1) {
75
+ s.ball.y = 1;
76
+ velocity = Math.abs(velocity);
77
+ }
78
+ const head = headingPose(sprite, s.x, s.jump).head;
79
+ if (
80
+ velocity > 0 && jumpTime >= 0 &&
81
+ touchesHead(previous, s.ball, previousHead, head)
82
+ ) {
83
+ s.score++;
84
+ const contact = head.filter(pixel =>
85
+ s.ball.x + 2 > pixel.x && s.ball.x < pixel.x + 1);
86
+ s.ball.y = Math.min(...contact.map(pixel => pixel.y)) - 2;
87
+ velocity = -0.016 - Math.floor(s.score / 3) * 0.001;
88
+ if (s.score >= 10) {
89
+ s.result = "win";
90
+ s.phase = "done";
91
+ }
92
+ } else if (s.ball.y >= FIELD_HEIGHT - 2) {
93
+ s.misses++;
94
+ s.phase = "countdown";
95
+ s.phaseTime = 0;
96
+ s.ball.y = FIELD_HEIGHT - 2;
97
+ }
98
+ }
99
+ if (!s.result && (s.misses >= 3 || !s.remaining)) {
100
+ s.result = "loss";
101
+ s.phase = "done";
102
+ }
103
+ },
104
+ (action) => {
105
+ if (["left", "right"].includes(action) && s.elapsed - moveTime >= 100) {
106
+ s.x = clamp(
107
+ s.x + (action === "left" ? -2 : 2),
108
+ halfWidth,
109
+ FIELD_WIDTH - halfWidth,
110
+ );
111
+ moveTime = s.elapsed;
112
+ }
113
+ if (action === "jump" && s.phase === "playing" && jumpTime < 0)
114
+ jumpTime = 0;
115
+ },
116
+ );
117
+ }
@@ -0,0 +1,96 @@
1
+ export function createHoops({ slow = false } = {}) {
2
+ const period = slow ? 4000 : 2400;
3
+ let phase = "ready",
4
+ result = null,
5
+ paused = false,
6
+ shot = 1,
7
+ score = 0;
8
+ let elapsed = 0,
9
+ arcElapsed = 0,
10
+ made = null,
11
+ marker = 0;
12
+ const target = () => ({
13
+ min: 0.5 - Math.max(0.08, 0.18 - score * 0.02),
14
+ max: 0.5 + Math.max(0.08, 0.18 - score * 0.02),
15
+ });
16
+ return {
17
+ input(action) {
18
+ if (paused || result) return false;
19
+ if (phase === "ready" && ["ready", "shoot"].includes(action)) {
20
+ phase = "aim";
21
+ elapsed = 0;
22
+ marker = 0;
23
+ return true;
24
+ }
25
+ if (phase !== "aim" || action !== "shoot") return false;
26
+ const bounds = target();
27
+ made =
28
+ marker + Number.EPSILON >= bounds.min &&
29
+ marker - Number.EPSILON <= bounds.max;
30
+ phase = "arc";
31
+ arcElapsed = 0;
32
+ return true;
33
+ },
34
+ advance(ms) {
35
+ if (!Number.isFinite(ms) || ms <= 0 || result || paused) return;
36
+ if (phase === "aim") {
37
+ elapsed += ms;
38
+ const cycle = (elapsed % period) / period;
39
+ marker = cycle <= 0.5 ? cycle * 2 : (1 - cycle) * 2;
40
+ } else if (phase === "arc") {
41
+ arcElapsed = Math.min(900, arcElapsed + ms);
42
+ if (arcElapsed === 900) {
43
+ if (made) score++;
44
+ if (shot === 5) {
45
+ phase = "done";
46
+ result = score >= 3 ? "win" : "loss";
47
+ } else {
48
+ shot++;
49
+ phase = "ready";
50
+ }
51
+ }
52
+ }
53
+ },
54
+ pause() {
55
+ if (!result) paused = true;
56
+ },
57
+ resume() {
58
+ paused = false;
59
+ },
60
+ quit() {
61
+ if (!result) {
62
+ result = "quit";
63
+ phase = "done";
64
+ }
65
+ },
66
+ snapshot() {
67
+ const t = ["arc", "done"].includes(phase) ? arcElapsed / 900 : 0;
68
+ // A made shot reaches the rim on its way down, then drops through the net.
69
+ const rimTime = 0.72;
70
+ const flight = Math.min(1, t / rimTime);
71
+ const endX = made ? 26 : marker < 0.5 ? 21 : 29;
72
+ const ball = t <= rimTime
73
+ ? {
74
+ x: 10 + (endX - 10) * flight,
75
+ y: 16 - 8 * flight - 9 * Math.sin(Math.PI * flight),
76
+ }
77
+ : { x: endX, y: 8 + 7 * ((t - rimTime) / (1 - rimTime)) };
78
+ return {
79
+ id: "hoops",
80
+ phase,
81
+ result,
82
+ paused,
83
+ shot,
84
+ shots: 5,
85
+ score,
86
+ targetScore: 3,
87
+ marker,
88
+ target: target(),
89
+ made,
90
+ arcProgress: t,
91
+ hoop: { centerX: 27, rimY: 9, left: 24, right: 30 },
92
+ ball: { x: Math.round(ball.x), y: Math.round(ball.y) },
93
+ };
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,81 @@
1
+ import { model, randomIndex } from "./model.js";
2
+ export function createMemory({ random = Math.random, slow = false } = {}) {
3
+ const sequence = Array.from(
4
+ { length: 6 },
5
+ () => ["left", "up", "right"][randomIndex(random, 3)],
6
+ );
7
+ const s = {
8
+ id: "memory",
9
+ phase: "watch",
10
+ result: null,
11
+ paused: false,
12
+ round: 1,
13
+ length: 2,
14
+ cue: sequence[0],
15
+ cueIndex: 0,
16
+ entered: 0,
17
+ lastInput: null,
18
+ inputRemaining: 0,
19
+ phaseTime: 0,
20
+ };
21
+ const duration = slow ? 1000 : 600,
22
+ gap = slow ? 350 : 200;
23
+ const restart = () => {
24
+ s.phase = "watch";
25
+ s.phaseTime = 0;
26
+ s.cueIndex = 0;
27
+ s.cue = sequence[0];
28
+ s.entered = 0;
29
+ };
30
+ return model(
31
+ s,
32
+ (dt) => {
33
+ s.inputRemaining = Math.max(0, s.inputRemaining - dt);
34
+ if (!s.inputRemaining) s.lastInput = null;
35
+ if (s.phase === "answer") return;
36
+ s.phaseTime += dt;
37
+ if (s.phase === "between" && s.phaseTime >= 450) {
38
+ restart();
39
+ return;
40
+ }
41
+ if (s.phase === "watch" && s.phaseTime >= duration) {
42
+ s.cue = null;
43
+ s.phase = "gap";
44
+ s.phaseTime = 0;
45
+ } else if (s.phase === "gap" && s.phaseTime >= gap) {
46
+ s.phaseTime = 0;
47
+ s.cueIndex++;
48
+ if (s.cueIndex >= s.length) s.phase = "answer";
49
+ else {
50
+ s.phase = "watch";
51
+ s.cue = sequence[s.cueIndex];
52
+ }
53
+ }
54
+ },
55
+ (action) => {
56
+ if (s.phase !== "answer" || !["left", "up", "right"].includes(action))
57
+ return;
58
+ s.lastInput = action;
59
+ s.inputRemaining = 450;
60
+ if (action !== sequence[s.entered]) {
61
+ s.result = "loss";
62
+ s.phase = "done";
63
+ return;
64
+ }
65
+ if (++s.entered === s.length) {
66
+ if (s.round === 5) {
67
+ s.result = "win";
68
+ s.phase = "done";
69
+ } else {
70
+ s.round++;
71
+ s.length++;
72
+ s.phase = "between";
73
+ s.phaseTime = 0;
74
+ }
75
+ }
76
+ },
77
+ () => {
78
+ if (s.phase === "watch" || s.phase === "gap") restart();
79
+ },
80
+ );
81
+ }
@@ -0,0 +1,40 @@
1
+ export function model(state, step, input, onResume = () => {}) {
2
+ let remainder = 0;
3
+ const active = () => !state.result && !state.paused;
4
+ return {
5
+ input(action) {
6
+ if (active()) input(action);
7
+ },
8
+ advance(ms) {
9
+ if (!Number.isFinite(ms) || ms < 0)
10
+ throw new RangeError("Elapsed time must be finite and positive");
11
+ if (!active()) return;
12
+ remainder += ms;
13
+ while (remainder >= 10 && active()) {
14
+ remainder -= 10;
15
+ step(10);
16
+ }
17
+ },
18
+ snapshot() {
19
+ return structuredClone(state);
20
+ },
21
+ pause() {
22
+ if (!state.result) state.paused = true;
23
+ },
24
+ resume() {
25
+ if (state.paused && !state.result) {
26
+ state.paused = false;
27
+ onResume();
28
+ }
29
+ },
30
+ quit() {
31
+ if (!state.result) {
32
+ state.result = "quit";
33
+ state.phase = "done";
34
+ }
35
+ },
36
+ };
37
+ }
38
+ export const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
39
+ export const randomIndex = (random, length) =>
40
+ clamp(Math.floor(random() * length), 0, length - 1);
@@ -0,0 +1,103 @@
1
+ // Both input modes advance at most once each half second.
2
+ export function createSprint({ hold = false, slow = false } = {}) {
3
+ const finish = 30,
4
+ tapInterval = 500,
5
+ opponentDuration = slow ? 20000 : 18000;
6
+ let phase = "countdown",
7
+ result = null,
8
+ paused = false,
9
+ countdown = 3000;
10
+ let elapsed = 0,
11
+ progress = 0,
12
+ lastTap = -tapInterval,
13
+ holding = false,
14
+ nextHold = 0,
15
+ queuedTap = false;
16
+ function settle() {
17
+ if (progress >= finish) {
18
+ progress = finish;
19
+ phase = "done";
20
+ result = "win";
21
+ } else if (elapsed >= opponentDuration) {
22
+ phase = "done";
23
+ result = "loss";
24
+ }
25
+ }
26
+ return {
27
+ input(action) {
28
+ if (paused || result || phase !== "race") return false;
29
+ if (hold && ["hold", "start-hold", "stop-hold"].includes(action)) {
30
+ holding = action === "hold" ? !holding : action === "start-hold";
31
+ nextHold = Math.max(elapsed, lastTap + tapInterval);
32
+ return true;
33
+ }
34
+ if (!hold && action === "run" && elapsed - lastTap >= tapInterval) {
35
+ lastTap = elapsed;
36
+ queuedTap = false;
37
+ progress++;
38
+ settle();
39
+ return true;
40
+ }
41
+ // Keep one early tap for the next step, never a burst of queued steps.
42
+ if (!hold && action === "run") queuedTap = true;
43
+ return false;
44
+ },
45
+ advance(ms) {
46
+ if (!Number.isFinite(ms) || ms <= 0 || result || paused) return;
47
+ if (phase === "countdown") {
48
+ const step = Math.min(countdown, ms);
49
+ countdown -= step;
50
+ ms -= step;
51
+ if (countdown === 0) phase = "race";
52
+ }
53
+ if (phase !== "race") return;
54
+ const end = Math.min(opponentDuration, elapsed + ms);
55
+ if (!hold && queuedTap && lastTap + tapInterval <= end) {
56
+ elapsed = lastTap + tapInterval;
57
+ lastTap = elapsed;
58
+ queuedTap = false;
59
+ progress++;
60
+ settle();
61
+ }
62
+ while (hold && holding && nextHold <= end && !result) {
63
+ elapsed = nextHold;
64
+ progress++;
65
+ lastTap = elapsed;
66
+ nextHold += tapInterval;
67
+ settle();
68
+ }
69
+ if (!result) {
70
+ elapsed = end;
71
+ settle();
72
+ }
73
+ },
74
+ pause() {
75
+ if (!result) paused = true;
76
+ },
77
+ resume() {
78
+ paused = false;
79
+ },
80
+ quit() {
81
+ if (!result) {
82
+ result = "quit";
83
+ phase = "done";
84
+ }
85
+ },
86
+ snapshot() {
87
+ return {
88
+ id: "sprint",
89
+ phase,
90
+ result,
91
+ paused,
92
+ countdown,
93
+ elapsed,
94
+ progress,
95
+ finish,
96
+ opponent: Math.min(finish, (elapsed / opponentDuration) * finish),
97
+ holding,
98
+ hold,
99
+ opponentDuration,
100
+ };
101
+ },
102
+ };
103
+ }