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,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
|
+
}
|