novac 2.3.0 → 2.4.0
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/kits/kitarcade/kitdef.js +2709 -0
- package/kits/kitascii/kitdef.js +839 -0
- package/kits/kitlife/kitdef.js +575 -0
- package/kits/kitpet/kitdef.js +752 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2709 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* kitarcade — Terminal games kit
|
|
5
|
+
*
|
|
6
|
+
* Every game object shares this API:
|
|
7
|
+
* .start() restart / begin
|
|
8
|
+
* .input(key) key names: 'up' 'down' 'left' 'right' 'space' 'enter'
|
|
9
|
+
* 'escape' 'tab' and any single char ('a'–'z', '0'–'9')
|
|
10
|
+
* .tick(dt = 16) advance by dt ms; drive at your preferred fps
|
|
11
|
+
* .frame() → ANSI string ready to write to terminal
|
|
12
|
+
* .state → 'idle' | 'playing' | 'paused' | 'gameover' | 'won'
|
|
13
|
+
* .score → number
|
|
14
|
+
* .on(ev, fn) events: 'start' 'gameover' 'win' 'score'
|
|
15
|
+
* .off(ev, fn)
|
|
16
|
+
*
|
|
17
|
+
* Quick-start:
|
|
18
|
+
* const { kitdef } = require('./kitarcade');
|
|
19
|
+
* const game = kitdef.Snake();
|
|
20
|
+
* game.start();
|
|
21
|
+
* process.stdin.setRawMode(true);
|
|
22
|
+
* process.stdin.on('data', buf => {
|
|
23
|
+
* const k = buf.toString();
|
|
24
|
+
* if (k === '\x1b[A') game.input('up');
|
|
25
|
+
* else if (k === '\x1b[B') game.input('down');
|
|
26
|
+
* else if (k === '\x1b[C') game.input('right');
|
|
27
|
+
* else if (k === '\x1b[D') game.input('left');
|
|
28
|
+
* else if (k === ' ') game.input('space');
|
|
29
|
+
* else if (k === '\r') game.input('enter');
|
|
30
|
+
* else if (k === '\x1b') game.input('escape');
|
|
31
|
+
* else game.input(k.toLowerCase());
|
|
32
|
+
* });
|
|
33
|
+
* setInterval(() => {
|
|
34
|
+
* process.stdout.write('\x1b[H\x1b[2J' + game.frame());
|
|
35
|
+
* if (game.state === 'gameover') process.exit();
|
|
36
|
+
* }, 50);
|
|
37
|
+
*
|
|
38
|
+
* // or simply:
|
|
39
|
+
* kitdef.RunGame(kitdef.Snake());
|
|
40
|
+
*
|
|
41
|
+
* the RunGame helper sets up the stdin and tick loop for you, and handles clean exit on gameover.
|
|
42
|
+
* the manual setup is there if you want more control, e.g. for a custom tick rate, or to run multiple games at once, or even custom input handling.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// ─── SHARED UTILITIES ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const FC = {
|
|
48
|
+
blk:30,red:31,grn:32,yel:33,blu:34,mag:35,cyn:36,wht:37,
|
|
49
|
+
BLK:90,RED:91,GRN:92,YEL:93,BLU:94,MAG:95,CYN:96,WHT:97,
|
|
50
|
+
};
|
|
51
|
+
const BC = {
|
|
52
|
+
blk:40,red:41,grn:42,yel:43,blu:44,mag:45,cyn:46,wht:47,
|
|
53
|
+
BLK:100,RED:101,GRN:102,YEL:103,BLU:104,MAG:105,CYN:106,WHT:107,
|
|
54
|
+
};
|
|
55
|
+
const RST = '\x1b[0m';
|
|
56
|
+
const BLD = '\x1b[1m';
|
|
57
|
+
const DIM = '\x1b[2m';
|
|
58
|
+
|
|
59
|
+
function makeCanvas(W, H) {
|
|
60
|
+
const ch = Array.from({length:H}, () => new Array(W).fill(' '));
|
|
61
|
+
const fg = Array.from({length:H}, () => new Array(W).fill(FC.wht));
|
|
62
|
+
const bg = Array.from({length:H}, () => new Array(W).fill(BC.blk));
|
|
63
|
+
const cv = {
|
|
64
|
+
W, H,
|
|
65
|
+
clear(c=' ', f=FC.wht, b=BC.blk) {
|
|
66
|
+
for (let y=0;y<H;y++) for (let x=0;x<W;x++) { ch[y][x]=c;fg[y][x]=f;bg[y][x]=b; }
|
|
67
|
+
},
|
|
68
|
+
set(x, y, c, f=FC.wht, b=BC.blk) {
|
|
69
|
+
if (x<0||x>=W||y<0||y>=H) return;
|
|
70
|
+
ch[y][x] = (String(c)[0])||' '; fg[y][x]=f; bg[y][x]=b;
|
|
71
|
+
},
|
|
72
|
+
put(x, y, s, f=FC.wht, b=BC.blk) {
|
|
73
|
+
for (let i=0;i<s.length;i++) cv.set(x+i, y, s[i], f, b);
|
|
74
|
+
},
|
|
75
|
+
fill(x, y, w, h, c, f=FC.wht, b=BC.blk) {
|
|
76
|
+
for (let dy=0;dy<h;dy++) for (let dx=0;dx<w;dx++) cv.set(x+dx,y+dy,c,f,b);
|
|
77
|
+
},
|
|
78
|
+
box(x, y, w, h, f=FC.wht, b=BC.blk) {
|
|
79
|
+
cv.put(x, y, '┌'+'─'.repeat(w-2)+'┐', f, b);
|
|
80
|
+
for (let dy=1;dy<h-1;dy++) { cv.set(x,y+dy,'│',f,b); cv.set(x+w-1,y+dy,'│',f,b); }
|
|
81
|
+
cv.put(x, y+h-1, '└'+'─'.repeat(w-2)+'┘', f, b);
|
|
82
|
+
},
|
|
83
|
+
render() {
|
|
84
|
+
const lines = [];
|
|
85
|
+
for (let y=0;y<H;y++) {
|
|
86
|
+
let row='', cf=-1, cb=-1;
|
|
87
|
+
for (let x=0;x<W;x++) {
|
|
88
|
+
const f=fg[y][x], b=bg[y][x];
|
|
89
|
+
if (f!==cf||b!==cb) { row+=`\x1b[${f};${b}m`; cf=f; cb=b; }
|
|
90
|
+
row += ch[y][x];
|
|
91
|
+
}
|
|
92
|
+
lines.push(row+RST);
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return cv;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function makeEmitter() {
|
|
101
|
+
const L = new Map();
|
|
102
|
+
return {
|
|
103
|
+
on(ev, fn) { if(!L.has(ev)) L.set(ev,[]); L.get(ev).push(fn); },
|
|
104
|
+
off(ev, fn) { if(L.has(ev)) L.set(ev, L.get(ev).filter(f=>f!==fn)); },
|
|
105
|
+
emit(ev, ...a) { (L.get(ev)??[]).forEach(fn=>fn(...a)); },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rng = (n) => Math.random() * n | 0;
|
|
110
|
+
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
|
|
111
|
+
function shuffle(a) {
|
|
112
|
+
const b = [...a];
|
|
113
|
+
for (let i=b.length-1;i>0;i--) { const j=rng(i+1); [b[i],b[j]]=[b[j],b[i]]; }
|
|
114
|
+
return b;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── WORD BANKS ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const WORDLE_WORDS = [
|
|
120
|
+
'about','above','abuse','actor','acute','admit','adopt','adult','after','again',
|
|
121
|
+
'agent','agree','ahead','alarm','album','alert','alike','alive','alley','allow',
|
|
122
|
+
'alone','along','angel','anger','angle','angry','ankle','apply','arena','argue',
|
|
123
|
+
'arise','armor','arson','array','aside','asset','atlas','attic','audio','avoid',
|
|
124
|
+
'award','aware','awful','bacon','badge','baker','basic','batch','beard','beast',
|
|
125
|
+
'bench','birth','black','blade','blank','blast','blaze','bleed','bless','blend',
|
|
126
|
+
'blind','blood','bloom','blues','board','bonus','boost','bound','boxer','brain',
|
|
127
|
+
'brand','brave','bread','break','breed','bride','brief','bring','broad','broke',
|
|
128
|
+
'brush','buddy','build','built','bunch','burst','buyer','cabin','cable','carry',
|
|
129
|
+
'catch','cause','chain','chaos','charm','check','chest','chose','chunk','civil',
|
|
130
|
+
'claim','class','clean','clear','click','cliff','climb','clock','clone','close',
|
|
131
|
+
'cloud','coach','coast','coral','could','count','cover','crack','craft','crane',
|
|
132
|
+
'crazy','crime','crisp','cross','crowd','crown','crush','cubic','curve','cycle',
|
|
133
|
+
'daily','dance','death','debut','delay','delta','dense','depot','derby','devil',
|
|
134
|
+
'dirty','diver','dizzy','dodge','doing','doubt','dough','douse','draft','drain',
|
|
135
|
+
'drama','dream','dress','drift','drink','drive','drone','drove','dwarf','dying',
|
|
136
|
+
'eagle','early','earth','eight','elite','empty','enemy','enjoy','enter','entry',
|
|
137
|
+
'equal','error','event','every','exact','exist','extra','fable','fairy','faith',
|
|
138
|
+
'false','fancy','fatal','fault','feast','fence','field','fifth','fifty','fight',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const HANGMAN_WORDS = [
|
|
142
|
+
'elephant','giraffe','penguin','dolphin','universe','mountain','keyboard','chocolate',
|
|
143
|
+
'adventure','symphony','democracy','laboratory','philosophy','electricity','caterpillar',
|
|
144
|
+
'metamorphosis','catastrophe','bibliophile','serendipity','extraordinary','constellation',
|
|
145
|
+
'hippopotamus','kaleidoscope','neighbourhood','thunderstorm','championship','algorithm',
|
|
146
|
+
'certificate','competition','deliberately','entertainment','establishment','infrastructure',
|
|
147
|
+
'investigation','knowledgeable','magnificent','opportunities','pharmaceutical','quarterback',
|
|
148
|
+
'refrigerator','subscription','transformation','uncomfortable','vulnerability','xylophone',
|
|
149
|
+
'accomplished','battlefield','comfortable','development','furthermore','government',
|
|
150
|
+
'helicopter','immediately','journalism','kindergarten','linguistics','masterpiece',
|
|
151
|
+
'notification','observatory','perspective','quadrilateral','refreshment','successfully',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
const TYPING_PHRASES = [
|
|
155
|
+
'the quick brown fox jumps over the lazy dog and keeps running through the forest',
|
|
156
|
+
'programming is the art of telling another human what one wants the computer to do',
|
|
157
|
+
'any sufficiently advanced technology is indistinguishable from magic according to clarke',
|
|
158
|
+
'in theory there is no difference between theory and practice but in practice there is',
|
|
159
|
+
'first solve the problem then write the code otherwise you are just writing bad code faster',
|
|
160
|
+
'the best error message is the one that never shows up because you prevented the bug',
|
|
161
|
+
'debugging is like being the detective in a crime movie where you are also the murderer',
|
|
162
|
+
'code is like humor when you have to explain it it is not that good at all really',
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
// ─── SUDOKU PUZZLES ───────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const SUDOKU_PUZZLES = [
|
|
168
|
+
'53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79',
|
|
169
|
+
'.......1.4.........2...........5.4.7..8...3....1.9....3..4..2...5.1........8.6...',
|
|
170
|
+
'..9748...7.........2.1.9.....7...24..64.1.59..98...3.....8.3.2.........6...2759..',
|
|
171
|
+
'1....7.9..3..2...8..96..5....53..9...1..8...26....4...3......1..4......7..7...3..',
|
|
172
|
+
'.3.5..8.5.5.9...2...7.8.1.7.4...5.......3..1...7...6.....5..........8.....6.....',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
// ─── GAME: SNAKE ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function makeSnake(opts = {}) {
|
|
178
|
+
const GW = opts.width ?? 30;
|
|
179
|
+
const GH = opts.height ?? 20;
|
|
180
|
+
const em = makeEmitter();
|
|
181
|
+
let snake, dir, nextDir, food, score, state, elapsed, speed;
|
|
182
|
+
|
|
183
|
+
function spawnFood() {
|
|
184
|
+
let fx, fy;
|
|
185
|
+
do { fx=rng(GW); fy=rng(GH); } while (snake.some(s=>s.x===fx&&s.y===fy));
|
|
186
|
+
food = {x:fx, y:fy};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function start() {
|
|
190
|
+
snake = [{x:5,y:GH>>1},{x:4,y:GH>>1},{x:3,y:GH>>1}];
|
|
191
|
+
dir = {dx:1,dy:0}; nextDir = {dx:1,dy:0};
|
|
192
|
+
score = 0; elapsed = 0; speed = 150; state = 'playing';
|
|
193
|
+
spawnFood(); em.emit('start');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function input(key) {
|
|
197
|
+
if (state === 'gameover' && key === 'enter') { start(); return; }
|
|
198
|
+
if (state !== 'playing') return;
|
|
199
|
+
const nd = { up:{dx:0,dy:-1}, down:{dx:0,dy:1}, left:{dx:-1,dy:0}, right:{dx:1,dy:0} }[key];
|
|
200
|
+
if (nd && !(nd.dx===-dir.dx && nd.dy===-dir.dy)) nextDir = nd;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function step() {
|
|
204
|
+
dir = nextDir;
|
|
205
|
+
const head = snake[0];
|
|
206
|
+
const nx = head.x + dir.dx, ny = head.y + dir.dy;
|
|
207
|
+
if (nx<0||nx>=GW||ny<0||ny>=GH||snake.some(s=>s.x===nx&&s.y===ny)) {
|
|
208
|
+
state = 'gameover'; em.emit('gameover', score); return;
|
|
209
|
+
}
|
|
210
|
+
snake.unshift({x:nx,y:ny});
|
|
211
|
+
if (nx===food.x && ny===food.y) {
|
|
212
|
+
score += 10; speed = Math.max(60, speed - 2);
|
|
213
|
+
em.emit('score', score); spawnFood();
|
|
214
|
+
} else { snake.pop(); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function tick(dt=16) {
|
|
218
|
+
if (state !== 'playing') return;
|
|
219
|
+
elapsed += dt;
|
|
220
|
+
while (elapsed >= speed) { elapsed -= speed; step(); if (state!=='playing') break; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function frame() {
|
|
224
|
+
const cv = makeCanvas(GW*2+2, GH+3);
|
|
225
|
+
cv.clear(' ', FC.wht, BC.blk);
|
|
226
|
+
// title
|
|
227
|
+
cv.put(0, 0, ` SNAKE Score: ${score} Length: ${snake.length} [WASD/Arrows]`, FC.YEL, BC.blk);
|
|
228
|
+
// border
|
|
229
|
+
cv.box(0, 1, GW*2+2, GH+2, FC.wht, BC.blk);
|
|
230
|
+
// food
|
|
231
|
+
cv.put(food.x*2+1, food.y+2, '()', FC.RED, BC.blk);
|
|
232
|
+
// snake
|
|
233
|
+
for (let i=snake.length-1;i>=0;i--) {
|
|
234
|
+
const s=snake[i];
|
|
235
|
+
const isHead = i===0;
|
|
236
|
+
const col = isHead ? FC.GRN : (i%2===0 ? FC.grn : FC.GRN);
|
|
237
|
+
cv.put(s.x*2+1, s.y+2, isHead?'◉◉':'██', col, BC.blk);
|
|
238
|
+
}
|
|
239
|
+
if (state==='gameover') {
|
|
240
|
+
const msg=' GAME OVER Press ENTER ';
|
|
241
|
+
cv.put((GW*2+2-msg.length)>>1, (GH>>1)+2, msg, FC.WHT, BC.red);
|
|
242
|
+
}
|
|
243
|
+
return cv.render();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { start, input, tick, frame,
|
|
247
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
248
|
+
get state(){return state;}, get score(){return score;} };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── GAME: TETRIS ─────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const PIECES = {
|
|
254
|
+
I:{col:FC.CYN,shapes:[[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],[[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]],[[0,0,0,0],[0,0,0,0],[1,1,1,1],[0,0,0,0]],[[0,1,0,0],[0,1,0,0],[0,1,0,0],[0,1,0,0]]]},
|
|
255
|
+
O:{col:FC.YEL,shapes:[[[1,1],[1,1]],[[1,1],[1,1]],[[1,1],[1,1]],[[1,1],[1,1]]]},
|
|
256
|
+
T:{col:FC.MAG,shapes:[[[0,1,0],[1,1,1],[0,0,0]],[[0,1,0],[0,1,1],[0,1,0]],[[0,0,0],[1,1,1],[0,1,0]],[[0,1,0],[1,1,0],[0,1,0]]]},
|
|
257
|
+
S:{col:FC.GRN,shapes:[[[0,1,1],[1,1,0],[0,0,0]],[[0,1,0],[0,1,1],[0,0,1]],[[0,0,0],[0,1,1],[1,1,0]],[[1,0,0],[1,1,0],[0,1,0]]]},
|
|
258
|
+
Z:{col:FC.RED,shapes:[[[1,1,0],[0,1,1],[0,0,0]],[[0,0,1],[0,1,1],[0,1,0]],[[0,0,0],[1,1,0],[0,1,1]],[[0,1,0],[1,1,0],[1,0,0]]]},
|
|
259
|
+
J:{col:FC.BLU,shapes:[[[1,0,0],[1,1,1],[0,0,0]],[[0,1,1],[0,1,0],[0,1,0]],[[0,0,0],[1,1,1],[0,0,1]],[[0,1,0],[0,1,0],[1,1,0]]]},
|
|
260
|
+
L:{col:FC.yel,shapes:[[[0,0,1],[1,1,1],[0,0,0]],[[0,1,0],[0,1,0],[0,1,1]],[[0,0,0],[1,1,1],[1,0,0]],[[1,1,0],[0,1,0],[0,1,0]]]},
|
|
261
|
+
};
|
|
262
|
+
const PIECE_KEYS = Object.keys(PIECES);
|
|
263
|
+
const SCORE_TABLE = [0,100,300,500,800];
|
|
264
|
+
|
|
265
|
+
function makeTetris(opts = {}) {
|
|
266
|
+
const BW=10, BH=20;
|
|
267
|
+
const em = makeEmitter();
|
|
268
|
+
let board, colors, current, rotation, px, py, hold, holdUsed, bag, next;
|
|
269
|
+
let score, level, lines, state, elapsed, gravity;
|
|
270
|
+
|
|
271
|
+
function newBag() { return shuffle(PIECE_KEYS); }
|
|
272
|
+
function nextPiece() {
|
|
273
|
+
if (!bag.length) bag = newBag();
|
|
274
|
+
return bag.shift();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function spawn(key) {
|
|
278
|
+
current = key; rotation = 0;
|
|
279
|
+
const shape = PIECES[key].shapes[0];
|
|
280
|
+
px = (BW - shape[0].length) >> 1; py = 0;
|
|
281
|
+
if (collides(px, py, rotation)) { state='gameover'; em.emit('gameover',score); }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function collides(tx, ty, rot) {
|
|
285
|
+
const shape = PIECES[current].shapes[rot];
|
|
286
|
+
for (let r=0;r<shape.length;r++)
|
|
287
|
+
for (let c=0;c<shape[r].length;c++)
|
|
288
|
+
if (shape[r][c]) {
|
|
289
|
+
const nx=tx+c, ny=ty+r;
|
|
290
|
+
if (nx<0||nx>=BW||ny>=BH) return true;
|
|
291
|
+
if (ny>=0 && board[ny*BW+nx]) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function lock() {
|
|
297
|
+
const shape = PIECES[current].shapes[rotation];
|
|
298
|
+
const col = PIECES[current].col;
|
|
299
|
+
for (let r=0;r<shape.length;r++)
|
|
300
|
+
for (let c=0;c<shape[r].length;c++)
|
|
301
|
+
if (shape[r][c] && py+r>=0) {
|
|
302
|
+
board[(py+r)*BW+(px+c)] = 1;
|
|
303
|
+
colors[(py+r)*BW+(px+c)] = col;
|
|
304
|
+
}
|
|
305
|
+
// clear lines
|
|
306
|
+
let cleared = 0;
|
|
307
|
+
for (let r=BH-1;r>=0;r--) {
|
|
308
|
+
if ([...Array(BW)].every((_,c)=>board[r*BW+c])) {
|
|
309
|
+
board.splice(r*BW, BW); colors.splice(r*BW, BW);
|
|
310
|
+
board.unshift(...new Array(BW).fill(0));
|
|
311
|
+
colors.unshift(...new Array(BW).fill(0));
|
|
312
|
+
cleared++; r++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (cleared) {
|
|
316
|
+
lines += cleared; score += SCORE_TABLE[cleared] * level;
|
|
317
|
+
level = Math.floor(lines/10)+1; gravity = Math.max(50, 1000-level*90);
|
|
318
|
+
em.emit('score', score);
|
|
319
|
+
}
|
|
320
|
+
holdUsed = false;
|
|
321
|
+
spawn(nextPiece());
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function start() {
|
|
325
|
+
board = new Array(BW*BH).fill(0);
|
|
326
|
+
colors = new Array(BW*BH).fill(0);
|
|
327
|
+
bag = newBag(); next = newBag(); hold = null; holdUsed = false;
|
|
328
|
+
score = 0; level = 1; lines = 0; elapsed = 0; gravity = 1000; state = 'playing';
|
|
329
|
+
spawn(nextPiece()); em.emit('start');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function doHold() {
|
|
333
|
+
if (holdUsed) return;
|
|
334
|
+
holdUsed = true;
|
|
335
|
+
const prev = hold;
|
|
336
|
+
hold = current;
|
|
337
|
+
spawn(prev ?? nextPiece());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function input(key) {
|
|
341
|
+
if (state==='gameover'&&key==='enter') { start(); return; }
|
|
342
|
+
if (state!=='playing') return;
|
|
343
|
+
if (key==='left' && !collides(px-1,py,rotation)) px--;
|
|
344
|
+
if (key==='right' && !collides(px+1,py,rotation)) px++;
|
|
345
|
+
if (key==='down' && !collides(px,py+1,rotation)) { py++; score+=1; }
|
|
346
|
+
if (key==='up'||key==='z') {
|
|
347
|
+
const nr=(rotation+1)%4;
|
|
348
|
+
if (!collides(px,py,nr)) rotation=nr;
|
|
349
|
+
else if (!collides(px-1,py,nr)) { px--; rotation=nr; }
|
|
350
|
+
else if (!collides(px+1,py,nr)) { px++; rotation=nr; }
|
|
351
|
+
}
|
|
352
|
+
if (key==='space') { // hard drop
|
|
353
|
+
while (!collides(px,py+1,rotation)) { py++; score+=2; }
|
|
354
|
+
lock();
|
|
355
|
+
}
|
|
356
|
+
if (key==='c'||key==='tab') doHold();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function tick(dt=16) {
|
|
360
|
+
if (state!=='playing') return;
|
|
361
|
+
elapsed+=dt;
|
|
362
|
+
if (elapsed>=gravity) { elapsed=0; if (!collides(px,py+1,rotation)) py++; else lock(); }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function ghostY() {
|
|
366
|
+
let gy=py;
|
|
367
|
+
while (!collides(px,gy+1,rotation)) gy++;
|
|
368
|
+
return gy;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function frame() {
|
|
372
|
+
const SIDE=14; const CW=BW*2+2+SIDE+2;
|
|
373
|
+
const cv=makeCanvas(CW, BH+3); cv.clear(' ',FC.wht,BC.blk);
|
|
374
|
+
cv.put(0,0,`${BLD} TETRIS ${RST} Lv:${level} Lines:${lines} Score:${score} [←→↓ Move ↑ Rotate Space Hard-drop C Hold]`,FC.YEL,BC.blk);
|
|
375
|
+
// board border
|
|
376
|
+
cv.box(0,1,BW*2+2,BH+2,FC.wht,BC.blk);
|
|
377
|
+
// board cells
|
|
378
|
+
for (let r=0;r<BH;r++) for (let c=0;c<BW;c++) {
|
|
379
|
+
if (board[r*BW+c]) cv.put(c*2+1,r+2,'██',colors[r*BW+c],BC.blk);
|
|
380
|
+
}
|
|
381
|
+
// ghost
|
|
382
|
+
const gy=ghostY();
|
|
383
|
+
const gs=PIECES[current].shapes[rotation];
|
|
384
|
+
for (let r=0;r<gs.length;r++) for (let c=0;c<gs[r].length;c++)
|
|
385
|
+
if (gs[r][c] && gy+r>=0) cv.put((px+c)*2+1,gy+r+2,'░░',FC.BLK,BC.blk);
|
|
386
|
+
// current piece
|
|
387
|
+
for (let r=0;r<gs.length;r++) for (let c=0;c<gs[r].length;c++)
|
|
388
|
+
if (gs[r][c] && py+r>=0) cv.put((px+c)*2+1,py+r+2,'██',PIECES[current].col,BC.blk);
|
|
389
|
+
// sidebar
|
|
390
|
+
const sx=BW*2+3;
|
|
391
|
+
cv.put(sx,2,'NEXT:',FC.CYN,BC.blk);
|
|
392
|
+
if (bag.length) {
|
|
393
|
+
const nk=bag[0], ns=PIECES[nk].shapes[0];
|
|
394
|
+
for (let r=0;r<ns.length;r++) for (let c=0;c<ns[r].length;c++)
|
|
395
|
+
if (ns[r][c]) cv.put(sx+c*2,3+r,'██',PIECES[nk].col,BC.blk);
|
|
396
|
+
}
|
|
397
|
+
cv.put(sx,8,`HOLD: ${hold??'---'}`,FC.CYN,BC.blk);
|
|
398
|
+
if (hold) {
|
|
399
|
+
const hs=PIECES[hold].shapes[0];
|
|
400
|
+
for (let r=0;r<hs.length;r++) for (let c=0;c<hs[r].length;c++)
|
|
401
|
+
if (hs[r][c]) cv.put(sx+c*2,9+r,'██',holdUsed?FC.BLK:PIECES[hold].col,BC.blk);
|
|
402
|
+
}
|
|
403
|
+
if (state==='gameover') cv.put(1,BH/2+2|0,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
404
|
+
return cv.render();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { start, input, tick, frame,
|
|
408
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
409
|
+
get state(){return state;}, get score(){return score;} };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── GAME: DINO RUN ──────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
function makeDinoRun(opts = {}) {
|
|
415
|
+
const W=80, H=9;
|
|
416
|
+
const GROUND=6, DINO_X=5;
|
|
417
|
+
const em=makeEmitter();
|
|
418
|
+
let dinoY, velY, onGround, obstacles, clouds, score, speed, elapsed, state, frame_n;
|
|
419
|
+
|
|
420
|
+
function start() {
|
|
421
|
+
dinoY=GROUND; velY=0; onGround=true;
|
|
422
|
+
obstacles=[]; clouds=[{x:60,y:1},{x:40,y:2},{x:20,y:1}];
|
|
423
|
+
score=0; speed=0.15; elapsed=0; frame_n=0; state='playing';
|
|
424
|
+
em.emit('start');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function input(key) {
|
|
428
|
+
if (state==='gameover'&&key==='enter') { start(); return; }
|
|
429
|
+
if (state!=='playing') return;
|
|
430
|
+
if ((key==='space'||key==='up')&&onGround) { velY=-1.4; onGround=false; }
|
|
431
|
+
if ((key==='down')&&!onGround) velY+=0.5; // fast fall
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function tick(dt=16) {
|
|
435
|
+
if (state!=='playing') return;
|
|
436
|
+
elapsed+=dt;
|
|
437
|
+
if (elapsed<50) return; elapsed=0; frame_n++;
|
|
438
|
+
|
|
439
|
+
// physics
|
|
440
|
+
velY+=0.2; dinoY+=velY;
|
|
441
|
+
if (dinoY>=GROUND) { dinoY=GROUND; velY=0; onGround=true; }
|
|
442
|
+
|
|
443
|
+
// scroll
|
|
444
|
+
speed = Math.min(0.45, 0.15 + score*0.0003);
|
|
445
|
+
const dx = Math.ceil(speed*10);
|
|
446
|
+
|
|
447
|
+
// obstacles
|
|
448
|
+
for (const o of obstacles) o.x -= dx;
|
|
449
|
+
obstacles = obstacles.filter(o=>o.x>-3);
|
|
450
|
+
if (!obstacles.length || obstacles[obstacles.length-1].x < 60-rng(30)) {
|
|
451
|
+
const h = 1+rng(2);
|
|
452
|
+
obstacles.push({x:W+2, h, w:1+rng(2)});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// clouds
|
|
456
|
+
for (const c of clouds) c.x -= 1;
|
|
457
|
+
clouds = clouds.filter(c=>c.x>-5);
|
|
458
|
+
if (clouds.length<3) clouds.push({x:W+5, y:1+rng(3)});
|
|
459
|
+
|
|
460
|
+
// score
|
|
461
|
+
score++; em.emit('score', score);
|
|
462
|
+
|
|
463
|
+
// collision: dino occupies cols DINO_X..DINO_X+1, rows dinoY-1..dinoY
|
|
464
|
+
const dy0=Math.round(dinoY)-1, dy1=Math.round(dinoY);
|
|
465
|
+
for (const o of obstacles) {
|
|
466
|
+
const ox0=o.x, ox1=o.x+o.w-1;
|
|
467
|
+
const oy0=GROUND-o.h, oy1=GROUND;
|
|
468
|
+
if (ox0<=DINO_X+1 && ox1>=DINO_X && oy0<=dy1 && oy1>=dy0) {
|
|
469
|
+
state='gameover'; em.emit('gameover',score); return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function frame() {
|
|
475
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
476
|
+
cv.put(0,0,` DINO RUN Score: ${score} Speed: x${speed.toFixed(2)} SPACE/UP to jump ENTER restart`,FC.YEL,BC.blk);
|
|
477
|
+
// clouds
|
|
478
|
+
for (const c of clouds) if (c.x>=0&&c.x<W) cv.put(c.x,c.y,'~~~',FC.BLK,BC.blk);
|
|
479
|
+
// ground
|
|
480
|
+
cv.put(0,GROUND+1,'─'.repeat(W),FC.wht,BC.blk);
|
|
481
|
+
cv.put(0,GROUND+2,'═'.repeat(W),FC.BLK,BC.blk);
|
|
482
|
+
// dino
|
|
483
|
+
const dy=Math.round(dinoY);
|
|
484
|
+
const blink = frame_n%4<2;
|
|
485
|
+
cv.put(DINO_X,dy-1, blink?'(Ö)':'(Ȯ)', FC.GRN,BC.blk);
|
|
486
|
+
cv.put(DINO_X,dy, onGround?(frame_n%4<2?'/|\\':'|/\\'):'(_)', FC.GRN,BC.blk);
|
|
487
|
+
// obstacles
|
|
488
|
+
for (const o of obstacles) {
|
|
489
|
+
if (o.x<0||o.x>=W) continue;
|
|
490
|
+
for (let h=0;h<o.h;h++) {
|
|
491
|
+
const row=GROUND-h;
|
|
492
|
+
for (let w=0;w<o.w;w++) cv.put(o.x+w,row,h===0?'╤':'║',FC.grn,BC.blk);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (state==='gameover') cv.put((W-28)>>1,4,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
496
|
+
return cv.render();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { start, input, tick, frame,
|
|
500
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
501
|
+
get state(){return state;}, get score(){return score;} };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ─── GAME: BREAKOUT ──────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
function makeBreakout(opts = {}) {
|
|
507
|
+
const W=50, H=24;
|
|
508
|
+
const ROWS=5, COLS=10, PAD_W=8, BALL_SPD=1;
|
|
509
|
+
const BROW_COLORS=[FC.RED,FC.YEL,FC.GRN,FC.CYN,FC.BLU];
|
|
510
|
+
const em=makeEmitter();
|
|
511
|
+
let bricks, ball, padX, score, lives, state, elapsed;
|
|
512
|
+
|
|
513
|
+
function makeBricks() {
|
|
514
|
+
return Array.from({length:ROWS}, (_,r)=>
|
|
515
|
+
Array.from({length:COLS}, (_,c)=>({alive:true, row:r, col:c}))
|
|
516
|
+
).flat();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function start() {
|
|
520
|
+
bricks=makeBricks();
|
|
521
|
+
padX=(W-PAD_W)>>1;
|
|
522
|
+
ball={x:25,y:18,dx:1,dy:-1};
|
|
523
|
+
score=0; lives=3; elapsed=0; state='playing';
|
|
524
|
+
em.emit('start');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function input(key) {
|
|
528
|
+
if (state==='gameover'&&key==='enter'){ start(); return; }
|
|
529
|
+
if (state==='won'&&key==='enter'){ start(); return; }
|
|
530
|
+
if (state!=='playing') return;
|
|
531
|
+
if (key==='left') padX=Math.max(0,padX-2);
|
|
532
|
+
if (key==='right') padX=Math.min(W-PAD_W,padX+2);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function step() {
|
|
536
|
+
let nx=ball.x+ball.dx, ny=ball.y+ball.dy;
|
|
537
|
+
// wall bounces
|
|
538
|
+
if (nx<=0||nx>=W-1) { ball.dx=-ball.dx; nx=ball.x+ball.dx; }
|
|
539
|
+
if (ny<=1) { ball.dy=-ball.dy; ny=ball.y+ball.dy; }
|
|
540
|
+
// paddle
|
|
541
|
+
if (ny>=H-3 && nx>=padX && nx<padX+PAD_W && ball.dy>0) {
|
|
542
|
+
ball.dy=-ball.dy;
|
|
543
|
+
ball.dx = ((nx-padX)/PAD_W*4-2)|0 || ball.dx;
|
|
544
|
+
ball.dx = clamp(ball.dx,-2,2); if(!ball.dx) ball.dx=1;
|
|
545
|
+
ny=ball.y+ball.dy;
|
|
546
|
+
}
|
|
547
|
+
// miss
|
|
548
|
+
if (ny>=H-1) {
|
|
549
|
+
lives--; if (!lives) { state='gameover'; em.emit('gameover',score); return; }
|
|
550
|
+
ball={x:25,y:18,dx:ball.dx>0?1:-1,dy:-1};
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// brick collision
|
|
554
|
+
for (const b of bricks) {
|
|
555
|
+
if (!b.alive) continue;
|
|
556
|
+
const bx=(b.col*5)+0, by=b.row+2;
|
|
557
|
+
if (nx>=bx&&nx<bx+5&&ny===by) {
|
|
558
|
+
b.alive=false; ball.dy=-ball.dy; ny=ball.y+ball.dy;
|
|
559
|
+
score+=10*(ROWS-b.row); em.emit('score',score); break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
ball.x=nx; ball.y=ny;
|
|
563
|
+
if (bricks.every(b=>!b.alive)) { state='won'; em.emit('win',score); }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function tick(dt=16) {
|
|
567
|
+
if (state!=='playing') return;
|
|
568
|
+
elapsed+=dt;
|
|
569
|
+
while (elapsed>=80) { elapsed-=80; step(); }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function frame() {
|
|
573
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
574
|
+
cv.put(0,0,` BREAKOUT Score:${score} Lives:${'♥'.repeat(lives)} [←→ Move]`,FC.YEL,BC.blk);
|
|
575
|
+
cv.put(0,1,'─'.repeat(W),FC.wht,BC.blk);
|
|
576
|
+
// bricks
|
|
577
|
+
for (const b of bricks) {
|
|
578
|
+
if (!b.alive) continue;
|
|
579
|
+
const bx=b.col*5, by=b.row+2;
|
|
580
|
+
cv.put(bx,by,'▓▓▓▓▓',BROW_COLORS[b.row],BC.blk);
|
|
581
|
+
}
|
|
582
|
+
// ball
|
|
583
|
+
cv.set(ball.x,ball.y,'●',FC.WHT,BC.blk);
|
|
584
|
+
// paddle
|
|
585
|
+
cv.put(padX,H-2,'▀'.repeat(PAD_W),FC.CYN,BC.blk);
|
|
586
|
+
cv.put(0,H-1,'─'.repeat(W),FC.BLK,BC.blk);
|
|
587
|
+
if (state==='gameover') cv.put((W-28)>>1,H>>1,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
588
|
+
if (state==='won') cv.put((W-24)>>1,H>>1,' YOU WIN! ENTER to play again ',FC.WHT,BC.grn);
|
|
589
|
+
return cv.render();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { start, input, tick, frame,
|
|
593
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
594
|
+
get state(){return state;}, get score(){return score;} };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ─── GAME: SPACE INVADERS ────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
function makeSpaceInvaders(opts = {}) {
|
|
600
|
+
const W=60,H=24,COLS=10,ROWS=4;
|
|
601
|
+
const em=makeEmitter();
|
|
602
|
+
let aliens,alienDir,alienDx,playerX,bullets,aBullets,score,lives,state,elapsed,fireTimer;
|
|
603
|
+
|
|
604
|
+
const ALIEN_CH = ['◉◉','▓▓','░░','▒▒'];
|
|
605
|
+
|
|
606
|
+
function spawnAliens() {
|
|
607
|
+
aliens=[];
|
|
608
|
+
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++)
|
|
609
|
+
aliens.push({x:c*5+5,y:r*2+2,alive:true,row:r});
|
|
610
|
+
alienDir=1; alienDx=0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function start() {
|
|
614
|
+
spawnAliens(); playerX=W>>1; bullets=[]; aBullets=[]; fireTimer=0;
|
|
615
|
+
score=0; lives=3; elapsed=0; state='playing'; em.emit('start');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function input(key) {
|
|
619
|
+
if (state==='gameover'&&key==='enter'){ start(); return; }
|
|
620
|
+
if (state!=='playing') return;
|
|
621
|
+
if (key==='left') playerX=Math.max(1,playerX-3);
|
|
622
|
+
if (key==='right') playerX=Math.min(W-2,playerX+3);
|
|
623
|
+
if (key==='space'||key==='z') bullets.push({x:playerX,y:H-4});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function step() {
|
|
627
|
+
const alive=aliens.filter(a=>a.alive);
|
|
628
|
+
if (!alive.length) { state='won'; em.emit('win',score); return; }
|
|
629
|
+
|
|
630
|
+
// move aliens
|
|
631
|
+
alienDx+=alienDir;
|
|
632
|
+
const minX=Math.min(...alive.map(a=>a.x));
|
|
633
|
+
const maxX=Math.max(...alive.map(a=>a.x))+2;
|
|
634
|
+
if (maxX>=W-1||minX<=1) {
|
|
635
|
+
alienDir=-alienDir;
|
|
636
|
+
for (const a of aliens) a.y++;
|
|
637
|
+
alienDx=0;
|
|
638
|
+
} else {
|
|
639
|
+
for (const a of aliens) a.x+=alienDir;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// aliens reached bottom
|
|
643
|
+
if (alive.some(a=>a.y>=H-4)) { state='gameover'; em.emit('gameover',score); return; }
|
|
644
|
+
|
|
645
|
+
// alien fire
|
|
646
|
+
fireTimer--;
|
|
647
|
+
if (fireTimer<=0) {
|
|
648
|
+
fireTimer=8+rng(12);
|
|
649
|
+
const shooter=alive[rng(alive.length)];
|
|
650
|
+
if (shooter) aBullets.push({x:shooter.x+1,y:shooter.y+1});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// move bullets
|
|
654
|
+
bullets = bullets.filter(b=>b.y>1).map(b=>({...b,y:b.y-2}));
|
|
655
|
+
aBullets = aBullets.filter(b=>b.y<H-2).map(b=>({...b,y:b.y+1}));
|
|
656
|
+
|
|
657
|
+
// bullet hits alien
|
|
658
|
+
for (const b of [...bullets]) {
|
|
659
|
+
for (const a of alive) {
|
|
660
|
+
if (Math.abs(b.x-a.x)<=1&&Math.abs(b.y-a.y)<=1) {
|
|
661
|
+
a.alive=false; bullets=bullets.filter(x=>x!==b);
|
|
662
|
+
score+=(ROWS-a.row)*10; em.emit('score',score); break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// alien bullet hits player
|
|
668
|
+
for (const b of aBullets) {
|
|
669
|
+
if (Math.abs(b.x-playerX)<=1&&b.y>=H-4) {
|
|
670
|
+
aBullets=aBullets.filter(x=>x!==b); lives--;
|
|
671
|
+
if (!lives) { state='gameover'; em.emit('gameover',score); return; }
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function tick(dt=16) {
|
|
677
|
+
if (state!=='playing') return;
|
|
678
|
+
elapsed+=dt;
|
|
679
|
+
const sp=Math.max(60,200-score/2|0);
|
|
680
|
+
while (elapsed>=sp) { elapsed-=sp; step(); if(state!=='playing') break; }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function frame() {
|
|
684
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
685
|
+
cv.put(0,0,` SPACE INVADERS Score:${score} Lives:${'♥'.repeat(lives)} [←→ Move SPACE Fire]`,FC.YEL,BC.blk);
|
|
686
|
+
cv.put(0,1,'═'.repeat(W),FC.BLU,BC.blk);
|
|
687
|
+
for (const a of aliens) if (a.alive)
|
|
688
|
+
cv.put(a.x,a.y,ALIEN_CH[a.row],FC.GRN-a.row,BC.blk);
|
|
689
|
+
for (const b of bullets) cv.set(b.x,b.y,'|',FC.WHT,BC.blk);
|
|
690
|
+
for (const b of aBullets) cv.set(b.x,b.y,'!',FC.RED,BC.blk);
|
|
691
|
+
cv.put(playerX-1,H-3,'╔═╗',FC.CYN,BC.blk);
|
|
692
|
+
cv.put(playerX-2,H-2,'▓▓▓▓▓',FC.CYN,BC.blk);
|
|
693
|
+
cv.put(0,H-1,'═'.repeat(W),FC.wht,BC.blk);
|
|
694
|
+
if (state==='gameover') cv.put((W-28)>>1,H>>1,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
695
|
+
if (state==='won') cv.put((W-24)>>1,H>>1,' CLEARED! ENTER for more ',FC.WHT,BC.grn);
|
|
696
|
+
return cv.render();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return { start, input, tick, frame,
|
|
700
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
701
|
+
get state(){return state;}, get score(){return score;} };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─── GAME: FLAPPY BIRD ───────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
function makeFlappyBird(opts = {}) {
|
|
707
|
+
const W=60,H=20,BIRD_X=10,GAP=6;
|
|
708
|
+
const em=makeEmitter();
|
|
709
|
+
let birdY,velY,pipes,score,state,elapsed,speed;
|
|
710
|
+
|
|
711
|
+
function start() {
|
|
712
|
+
birdY=H>>1; velY=0; pipes=[]; score=0; elapsed=0; speed=120; state='playing';
|
|
713
|
+
pipes.push({x:W+5, gap:4+rng(H-GAP-4)});
|
|
714
|
+
em.emit('start');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function input(key) {
|
|
718
|
+
if (state==='gameover'&&key==='enter'){ start(); return; }
|
|
719
|
+
if (state==='idle'&&(key==='space'||key==='enter')){ state='playing'; return; }
|
|
720
|
+
if (state!=='playing') return;
|
|
721
|
+
if (key==='space'||key==='up') velY=-1.1;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function step() {
|
|
725
|
+
velY+=0.25; birdY+=velY;
|
|
726
|
+
if (birdY<0||birdY>=H-1) { state='gameover'; em.emit('gameover',score); return; }
|
|
727
|
+
|
|
728
|
+
for (const p of pipes) {
|
|
729
|
+
p.x--;
|
|
730
|
+
if (Math.abs(p.x-BIRD_X)<2) {
|
|
731
|
+
if (birdY<p.gap||birdY>p.gap+GAP) { state='gameover'; em.emit('gameover',score); return; }
|
|
732
|
+
}
|
|
733
|
+
if (p.x===BIRD_X-1) { score++; speed=Math.max(60,speed-3); em.emit('score',score); }
|
|
734
|
+
}
|
|
735
|
+
pipes=pipes.filter(p=>p.x>-2);
|
|
736
|
+
if (!pipes.length||pipes[pipes.length-1].x<W-15+rng(5))
|
|
737
|
+
pipes.push({x:W+2,gap:2+rng(H-GAP-3)});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function tick(dt=16) {
|
|
741
|
+
if (state!=='playing') return;
|
|
742
|
+
elapsed+=dt;
|
|
743
|
+
while (elapsed>=speed) { elapsed-=speed; step(); if(state!=='playing') break; }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function frame() {
|
|
747
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.BLU,BC.blk);
|
|
748
|
+
cv.put(0,0,` FLAPPY BIRD Score:${score} SPACE to flap`,FC.YEL,BC.blk);
|
|
749
|
+
for (const p of pipes) {
|
|
750
|
+
if (p.x<0||p.x>=W) continue;
|
|
751
|
+
for (let y=1;y<H;y++) {
|
|
752
|
+
if (y<p.gap||y>p.gap+GAP)
|
|
753
|
+
cv.put(p.x,y,'│▓',FC.grn,BC.blk);
|
|
754
|
+
}
|
|
755
|
+
cv.put(p.x,p.gap,'╙─',FC.GRN,BC.blk);
|
|
756
|
+
cv.put(p.x,p.gap+GAP,'╒─',FC.GRN,BC.blk);
|
|
757
|
+
}
|
|
758
|
+
const by=Math.round(birdY);
|
|
759
|
+
cv.put(BIRD_X,by, velY<0?'>O)':'>o)',FC.YEL,BC.blk);
|
|
760
|
+
if (state==='gameover') cv.put((W-28)>>1,H>>1,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
761
|
+
return cv.render();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return { start, input, tick, frame,
|
|
765
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
766
|
+
get state(){return state;}, get score(){return score;} };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ─── GAME: ASTEROIDS (DODGE) ─────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
function makeAsteroids(opts = {}) {
|
|
772
|
+
const W=60,H=20;
|
|
773
|
+
const em=makeEmitter();
|
|
774
|
+
let px,py,rocks,score,state,elapsed,speed;
|
|
775
|
+
const ROCK_CHARS=['●','◉','◎','○','◈','◇'];
|
|
776
|
+
|
|
777
|
+
function spawnRock() {
|
|
778
|
+
return {x:W-1, y:rng(H-2)+1, w:1+rng(2), speed:1+rng(3)/3, ch:ROCK_CHARS[rng(ROCK_CHARS.length)]};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function start() {
|
|
782
|
+
px=10; py=H>>1; rocks=[]; score=0; elapsed=0; speed=60; state='playing';
|
|
783
|
+
for (let i=0;i<3;i++) rocks.push({...spawnRock(),x:W-5-rng(30)});
|
|
784
|
+
em.emit('start');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function input(key) {
|
|
788
|
+
if (state==='gameover'&&key==='enter'){ start(); return; }
|
|
789
|
+
if (state!=='playing') return;
|
|
790
|
+
if (key==='up') py=Math.max(1,py-1);
|
|
791
|
+
if (key==='down') py=Math.min(H-2,py+1);
|
|
792
|
+
if (key==='left') px=Math.max(0,px-2);
|
|
793
|
+
if (key==='right') px=Math.min(W-3,px+2);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function tick(dt=16) {
|
|
797
|
+
if (state!=='playing') return;
|
|
798
|
+
elapsed+=dt;
|
|
799
|
+
if (elapsed<speed) return; elapsed=0;
|
|
800
|
+
score++; speed=Math.max(30,60-score/30|0);
|
|
801
|
+
|
|
802
|
+
for (const r of rocks) r.x-=r.speed;
|
|
803
|
+
rocks=rocks.filter(r=>r.x>-3);
|
|
804
|
+
while (rocks.length<4+((score/100)|0)) rocks.push(spawnRock());
|
|
805
|
+
|
|
806
|
+
// collision
|
|
807
|
+
for (const r of rocks) {
|
|
808
|
+
if (Math.abs(r.x-px)<=2&&Math.abs(r.y-py)<=1) {
|
|
809
|
+
state='gameover'; em.emit('gameover',score); return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
em.emit('score',score);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function frame() {
|
|
816
|
+
const cv=makeCanvas(W,H); cv.clear('·',FC.BLK,BC.blk);
|
|
817
|
+
cv.put(0,0,` ASTEROIDS Score:${score} [WASD/Arrows dodge rocks]`,FC.YEL,BC.blk);
|
|
818
|
+
for (const r of rocks) {
|
|
819
|
+
const rx=Math.round(r.x);
|
|
820
|
+
if (rx>=0&&rx<W) cv.put(rx,r.y,r.ch.repeat(r.w),FC.RED,BC.blk);
|
|
821
|
+
}
|
|
822
|
+
cv.put(px,py,'<Ö>',FC.CYN,BC.blk);
|
|
823
|
+
if (state==='gameover') cv.put((W-28)>>1,H>>1,' GAME OVER ENTER to restart ',FC.WHT,BC.red);
|
|
824
|
+
return cv.render();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return { start, input, tick, frame,
|
|
828
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
829
|
+
get state(){return state;}, get score(){return score;} };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─── GAME: 2048 ──────────────────────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
function makeTwentyFortyEight(opts = {}) {
|
|
835
|
+
const SIZE=4;
|
|
836
|
+
const em=makeEmitter();
|
|
837
|
+
let board, score, best, state;
|
|
838
|
+
|
|
839
|
+
const TILE_COLORS = {
|
|
840
|
+
0:FC.BLK, 2:FC.wht, 4:FC.yel, 8:FC.YEL, 16:FC.mag, 32:FC.MAG,
|
|
841
|
+
64:FC.red, 128:FC.RED, 256:FC.grn, 512:FC.GRN, 1024:FC.cyn, 2048:FC.CYN,
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
function newBoard() { return Array.from({length:SIZE},()=>new Array(SIZE).fill(0)); }
|
|
845
|
+
function spawnTile(b) {
|
|
846
|
+
const empty=[];
|
|
847
|
+
for (let r=0;r<SIZE;r++) for (let c=0;c<SIZE;c++) if(!b[r][c]) empty.push([r,c]);
|
|
848
|
+
if (!empty.length) return;
|
|
849
|
+
const [r,c]=empty[rng(empty.length)];
|
|
850
|
+
b[r][c]=Math.random()<0.9?2:4;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function slide(row) {
|
|
854
|
+
const r=row.filter(x=>x);
|
|
855
|
+
let gain=0;
|
|
856
|
+
for (let i=0;i<r.length-1;i++) if(r[i]===r[i+1]) { r[i]*=2; gain+=r[i]; r.splice(i+1,1); }
|
|
857
|
+
while (r.length<SIZE) r.push(0);
|
|
858
|
+
return {row:r, gain};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function move(dir) {
|
|
862
|
+
let moved=false, gain=0;
|
|
863
|
+
const nb=newBoard();
|
|
864
|
+
for (let r=0;r<SIZE;r++) for (let c=0;c<SIZE;c++) nb[r][c]=board[r][c];
|
|
865
|
+
|
|
866
|
+
if (dir==='left'||dir==='right') {
|
|
867
|
+
for (let r=0;r<SIZE;r++) {
|
|
868
|
+
let row=nb[r]; if (dir==='right') row=[...row].reverse();
|
|
869
|
+
const {row:nr,gain:g}=slide(row);
|
|
870
|
+
if (dir==='right') nr.reverse();
|
|
871
|
+
if (nr.some((v,i)=>v!==nb[r][i])) moved=true;
|
|
872
|
+
nb[r]=nr; gain+=g;
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
for (let c=0;c<SIZE;c++) {
|
|
876
|
+
let col=nb.map(r=>r[c]); if (dir==='down') col=[...col].reverse();
|
|
877
|
+
const {row:nc,gain:g}=slide(col);
|
|
878
|
+
if (dir==='down') nc.reverse();
|
|
879
|
+
nc.forEach((v,r)=>{ if(v!==nb[r][c]) moved=true; nb[r][c]=v; });
|
|
880
|
+
gain+=g;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (!moved) return false;
|
|
884
|
+
board=nb; score+=gain; best=Math.max(best,score);
|
|
885
|
+
spawnTile(board); em.emit('score',score);
|
|
886
|
+
// check win
|
|
887
|
+
if (board.flat().includes(2048)) { state='won'; em.emit('win',score); }
|
|
888
|
+
// check game over
|
|
889
|
+
else if (!canMove()) { state='gameover'; em.emit('gameover',score); }
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function canMove() {
|
|
894
|
+
for (let r=0;r<SIZE;r++) for (let c=0;c<SIZE;c++) {
|
|
895
|
+
if (!board[r][c]) return true;
|
|
896
|
+
if (c<SIZE-1&&board[r][c]===board[r][c+1]) return true;
|
|
897
|
+
if (r<SIZE-1&&board[r][c]===board[r+1][c]) return true;
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function start() {
|
|
903
|
+
board=newBoard(); score=0; best=best??0; state='playing';
|
|
904
|
+
spawnTile(board); spawnTile(board); em.emit('start');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function input(key) {
|
|
908
|
+
if ((state==='gameover'||state==='won')&&key==='enter'){ start(); return; }
|
|
909
|
+
if (state!=='playing') return;
|
|
910
|
+
if (['up','down','left','right'].includes(key)) move(key);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function frame() {
|
|
914
|
+
const CW=5*SIZE+3, CH=SIZE*2+4;
|
|
915
|
+
const cv=makeCanvas(CW+20,CH); cv.clear(' ',FC.wht,BC.blk);
|
|
916
|
+
cv.put(0,0,` 2048 Score:${score} Best:${best} [Arrows]`,FC.YEL,BC.blk);
|
|
917
|
+
cv.box(0,1,CW,CH-1,FC.wht,BC.blk);
|
|
918
|
+
for (let r=0;r<SIZE;r++) for (let c=0;c<SIZE;c++) {
|
|
919
|
+
const v=board[r][c];
|
|
920
|
+
const col=TILE_COLORS[v]??FC.WHT;
|
|
921
|
+
const s=(v?String(v):'·').padStart(4,' ');
|
|
922
|
+
cv.put(c*5+1,r*2+2,s,col,BC.blk);
|
|
923
|
+
if (r<SIZE-1) cv.put(c*5+1,r*2+3,'────',FC.BLK,BC.blk);
|
|
924
|
+
}
|
|
925
|
+
if (state==='gameover') cv.put(1,(CH>>1),' GAME OVER ENTER restart ',FC.WHT,BC.red);
|
|
926
|
+
if (state==='won') cv.put(1,(CH>>1),' 2048! ENTER continue ',FC.WHT,BC.grn);
|
|
927
|
+
return cv.render();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return { start, input, tick(){}, frame,
|
|
931
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
932
|
+
get state(){return state;}, get score(){return score;} };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── GAME: MINESWEEPER ───────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
function makeMinesweeper(opts = {}) {
|
|
938
|
+
const GW=opts.w??9,GH=opts.h??9,MINES=opts.mines??10;
|
|
939
|
+
const em=makeEmitter();
|
|
940
|
+
let cells,cx,cy,state,score,revealed,firstClick;
|
|
941
|
+
|
|
942
|
+
function idx(x,y){return y*GW+x;}
|
|
943
|
+
function neighbors(x,y){
|
|
944
|
+
const n=[];
|
|
945
|
+
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++) {
|
|
946
|
+
if(dx||dy) { const nx=x+dx,ny=y+dy; if(nx>=0&&nx<GW&&ny>=0&&ny<GH) n.push([nx,ny]); }
|
|
947
|
+
}
|
|
948
|
+
return n;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function placeMines(safeX,safeY) {
|
|
952
|
+
let placed=0;
|
|
953
|
+
while(placed<MINES) {
|
|
954
|
+
const x=rng(GW),y=rng(GH);
|
|
955
|
+
if(!cells[idx(x,y)].mine&&!(Math.abs(x-safeX)<=1&&Math.abs(y-safeY)<=1)) {
|
|
956
|
+
cells[idx(x,y)].mine=true; placed++;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for(let y=0;y<GH;y++) for(let x=0;x<GW;x++)
|
|
960
|
+
cells[idx(x,y)].adj=neighbors(x,y).filter(([nx,ny])=>cells[idx(nx,ny)].mine).length;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function reveal(x,y) {
|
|
964
|
+
const c=cells[idx(x,y)];
|
|
965
|
+
if(c.revealed||c.flagged) return;
|
|
966
|
+
c.revealed=true; revealed++;
|
|
967
|
+
if(c.mine) { state='gameover'; em.emit('gameover',score); return; }
|
|
968
|
+
if(!c.adj) neighbors(x,y).forEach(([nx,ny])=>reveal(nx,ny));
|
|
969
|
+
if(revealed===GW*GH-MINES) { state='won'; score=1000; em.emit('win',score); }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function start() {
|
|
973
|
+
cells=Array.from({length:GW*GH},()=>({mine:false,revealed:false,flagged:false,adj:0}));
|
|
974
|
+
cx=GW>>1; cy=GH>>1; revealed=0; score=0; state='playing'; firstClick=true;
|
|
975
|
+
em.emit('start');
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function input(key) {
|
|
979
|
+
if((state==='gameover'||state==='won')&&key==='enter'){start();return;}
|
|
980
|
+
if(state!=='playing') return;
|
|
981
|
+
if(key==='up') cy=Math.max(0,cy-1);
|
|
982
|
+
if(key==='down') cy=Math.min(GH-1,cy+1);
|
|
983
|
+
if(key==='left') cx=Math.max(0,cx-1);
|
|
984
|
+
if(key==='right') cx=Math.min(GW-1,cx+1);
|
|
985
|
+
if(key==='space'||key==='enter') {
|
|
986
|
+
if(firstClick) { firstClick=false; placeMines(cx,cy); }
|
|
987
|
+
reveal(cx,cy);
|
|
988
|
+
}
|
|
989
|
+
if(key==='f') {
|
|
990
|
+
const c=cells[idx(cx,cy)];
|
|
991
|
+
if(!c.revealed) { c.flagged=!c.flagged; score+=c.flagged?5:-5; }
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function frame() {
|
|
996
|
+
const W=GW*3+4, H=GH+4;
|
|
997
|
+
const cv=makeCanvas(W+20,H); cv.clear(' ',FC.wht,BC.blk);
|
|
998
|
+
const flags=cells.filter(c=>c.flagged).length;
|
|
999
|
+
cv.put(0,0,` MINESWEEPER Mines:${MINES-flags} [Arrows Move SPACE reveal F flag ENTER restart]`,FC.YEL,BC.blk);
|
|
1000
|
+
const numColors=[FC.wht,FC.BLU,FC.GRN,FC.RED,FC.BLU,FC.RED,FC.CYN,FC.MAG,FC.wht];
|
|
1001
|
+
for(let y=0;y<GH;y++) {
|
|
1002
|
+
cv.put(0,y+2,'│',FC.wht,BC.blk);
|
|
1003
|
+
for(let x=0;x<GW;x++) {
|
|
1004
|
+
const c=cells[idx(x,y)];
|
|
1005
|
+
const isCur=x===cx&&y===cy;
|
|
1006
|
+
let disp=' ·',fg=FC.BLK;
|
|
1007
|
+
if(c.flagged&&!c.revealed) { disp=' ⚑'; fg=FC.RED; }
|
|
1008
|
+
else if(!c.revealed) { disp=isCur?' ▪':' ▫'; fg=isCur?FC.YEL:FC.BLK; }
|
|
1009
|
+
else if(c.mine) { disp=' ✸'; fg=FC.RED; }
|
|
1010
|
+
else if(c.adj) { disp=` ${c.adj}`; fg=numColors[c.adj]; }
|
|
1011
|
+
else { disp=' '; fg=FC.BLK; }
|
|
1012
|
+
cv.put(x*3+1,y+2,disp,fg,isCur&&!c.revealed?BC.BLK:BC.blk);
|
|
1013
|
+
}
|
|
1014
|
+
cv.put(GW*3+1,y+2,'│',FC.wht,BC.blk);
|
|
1015
|
+
}
|
|
1016
|
+
cv.put(0,1,'├'+'─'.repeat(GW*3)+'┤',FC.wht,BC.blk);
|
|
1017
|
+
cv.put(0,GH+2,'└'+'─'.repeat(GW*3)+'┘',FC.wht,BC.blk);
|
|
1018
|
+
if(state==='gameover') {
|
|
1019
|
+
for(const c of cells) if(c.mine&&!c.flagged) c.revealed=true;
|
|
1020
|
+
cv.put(1,GH+3,' BOOM! ENTER to restart ',FC.WHT,BC.red);
|
|
1021
|
+
}
|
|
1022
|
+
if(state==='won') cv.put(1,GH+3,' CLEARED! ENTER again ',FC.WHT,BC.grn);
|
|
1023
|
+
return cv.render();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return { start, input, tick(){}, frame,
|
|
1027
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1028
|
+
get state(){return state;}, get score(){return score;} };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ─── GAME: WORDLE ────────────────────────────────────────────────────────────
|
|
1032
|
+
|
|
1033
|
+
function makeWordle(opts = {}) {
|
|
1034
|
+
const em=makeEmitter();
|
|
1035
|
+
let target,guesses,current,state,score,msg;
|
|
1036
|
+
|
|
1037
|
+
function start() {
|
|
1038
|
+
target=WORDLE_WORDS[rng(WORDLE_WORDS.length)].toUpperCase();
|
|
1039
|
+
guesses=[]; current=''; state='playing'; score=0; msg='';
|
|
1040
|
+
em.emit('start');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function check(word) {
|
|
1044
|
+
const result=[];
|
|
1045
|
+
const tArr=[...target];
|
|
1046
|
+
const used=new Array(5).fill(false);
|
|
1047
|
+
// greens
|
|
1048
|
+
for(let i=0;i<5;i++) {
|
|
1049
|
+
if(word[i]===tArr[i]) { result[i]='green'; used[i]=true; }
|
|
1050
|
+
}
|
|
1051
|
+
// yellows
|
|
1052
|
+
for(let i=0;i<5;i++) {
|
|
1053
|
+
if(result[i]) continue;
|
|
1054
|
+
const j=tArr.findIndex((c,k)=>c===word[i]&&!used[k]);
|
|
1055
|
+
if(j>=0) { result[i]='yellow'; used[j]=true; }
|
|
1056
|
+
else result[i]='gray';
|
|
1057
|
+
}
|
|
1058
|
+
return result;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function input(key) {
|
|
1062
|
+
if((state==='gameover'||state==='won')&&key==='enter'){start();return;}
|
|
1063
|
+
if(state!=='playing') return;
|
|
1064
|
+
if(key==='enter') {
|
|
1065
|
+
if(current.length<5) { msg='Too short!'; return; }
|
|
1066
|
+
if(!WORDLE_WORDS.includes(current.toLowerCase())&&!WORDLE_WORDS.includes(current.toUpperCase())) { msg='Not in word list'; return; }
|
|
1067
|
+
const colors=check(current);
|
|
1068
|
+
guesses.push({word:current,colors});
|
|
1069
|
+
if(current===target) {
|
|
1070
|
+
score=Math.max(10,(7-guesses.length)*100); state='won';
|
|
1071
|
+
msg='Brilliant!'; em.emit('win',score); return;
|
|
1072
|
+
}
|
|
1073
|
+
if(guesses.length>=6) { state='gameover'; msg=`Word was: ${target}`; em.emit('gameover',0); return; }
|
|
1074
|
+
current=''; msg='';
|
|
1075
|
+
} else if(key==='backspace'||key==='delete') {
|
|
1076
|
+
current=current.slice(0,-1);
|
|
1077
|
+
} else if(/^[a-z]$/.test(key)&¤t.length<5) {
|
|
1078
|
+
current+=key.toUpperCase();
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const GC={green:FC.GRN,yellow:FC.YEL,gray:FC.BLK};
|
|
1083
|
+
|
|
1084
|
+
function frame() {
|
|
1085
|
+
const W=40,H=20;
|
|
1086
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1087
|
+
cv.put(0,0,' WORDLE [Type guess, ENTER submit, BACKSPACE]',FC.YEL,BC.blk);
|
|
1088
|
+
// grid
|
|
1089
|
+
for(let g=0;g<6;g++) {
|
|
1090
|
+
const guess=guesses[g];
|
|
1091
|
+
for(let l=0;l<5;l++) {
|
|
1092
|
+
const bx=l*6+2, by=g*2+2;
|
|
1093
|
+
if(guess) {
|
|
1094
|
+
const col=GC[guess.colors[l]];
|
|
1095
|
+
cv.put(bx,by,`[${guess.word[l]}]`,col,BC.blk);
|
|
1096
|
+
} else if(g===guesses.length&&l<current.length) {
|
|
1097
|
+
cv.put(bx,by,`[${current[l]}]`,FC.WHT,BC.blk);
|
|
1098
|
+
} else {
|
|
1099
|
+
cv.put(bx,by,'[ ]',FC.BLK,BC.blk);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// keyboard hint
|
|
1104
|
+
const kb='QWERTYUIOPASDFGHJKLZXCVBNM';
|
|
1105
|
+
const usedLetters=new Map();
|
|
1106
|
+
for(const g of guesses) {
|
|
1107
|
+
for(let i=0;i<5;i++) {
|
|
1108
|
+
const l=g.word[i], c=g.colors[i];
|
|
1109
|
+
if(!usedLetters.has(l)||c==='green'||(c==='yellow'&&usedLetters.get(l)==='gray'))
|
|
1110
|
+
usedLetters.set(l,c);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const rows=['QWERTYUIOP','ASDFGHJKL','ZXCVBNM'];
|
|
1114
|
+
for(let r=0;r<3;r++) {
|
|
1115
|
+
let x=2+r*3;
|
|
1116
|
+
for(const ch of rows[r]) {
|
|
1117
|
+
const col=GC[usedLetters.get(ch)]??FC.wht;
|
|
1118
|
+
cv.put(x,14+r,ch,col,BC.blk); x+=3;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if(msg) cv.put(2,13,msg.padEnd(30,' '),FC.RED,BC.blk);
|
|
1122
|
+
return cv.render();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return { start, input, tick(){}, frame,
|
|
1126
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1127
|
+
get state(){return state;}, get score(){return score;} };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ─── GAME: CONNECT FOUR ──────────────────────────────────────────────────────
|
|
1131
|
+
|
|
1132
|
+
function makeConnectFour(opts = {}) {
|
|
1133
|
+
const COLS=7,ROWS=6;
|
|
1134
|
+
const em=makeEmitter();
|
|
1135
|
+
let board,curCol,turn,state,score,aiMode,msg;
|
|
1136
|
+
|
|
1137
|
+
function start() {
|
|
1138
|
+
board=Array.from({length:ROWS},()=>new Array(COLS).fill(0));
|
|
1139
|
+
curCol=3; turn=1; state='playing'; score=0; msg='';
|
|
1140
|
+
aiMode=opts.ai??true;
|
|
1141
|
+
em.emit('start');
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function dropPiece(col,player) {
|
|
1145
|
+
for(let r=ROWS-1;r>=0;r--) {
|
|
1146
|
+
if(!board[r][col]) { board[r][col]=player; return r; }
|
|
1147
|
+
}
|
|
1148
|
+
return -1;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function checkWin(player) {
|
|
1152
|
+
// horizontal/vertical/diagonal
|
|
1153
|
+
for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++) {
|
|
1154
|
+
const dirs=[[0,1],[1,0],[1,1],[1,-1]];
|
|
1155
|
+
for(const [dr,dc] of dirs) {
|
|
1156
|
+
let cnt=0;
|
|
1157
|
+
for(let i=0;i<4;i++) {
|
|
1158
|
+
const nr=r+dr*i,nc=c+dc*i;
|
|
1159
|
+
if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS&&board[nr][nc]===player) cnt++;
|
|
1160
|
+
else break;
|
|
1161
|
+
}
|
|
1162
|
+
if(cnt===4) return true;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function aiMove() {
|
|
1169
|
+
const valid=[];
|
|
1170
|
+
for(let c=0;c<COLS;c++) if(board[0][c]===0) valid.push(c);
|
|
1171
|
+
if(!valid.length) return;
|
|
1172
|
+
// check win
|
|
1173
|
+
for(const c of valid) {
|
|
1174
|
+
const r=dropPiece(c,2); if(checkWin(2)) { board[r][c]=0; return c; } board[r][c]=0;
|
|
1175
|
+
}
|
|
1176
|
+
// block player win
|
|
1177
|
+
for(const c of valid) {
|
|
1178
|
+
const r=dropPiece(c,1); if(checkWin(1)) { board[r][c]=0; return c; } board[r][c]=0;
|
|
1179
|
+
}
|
|
1180
|
+
// prefer center
|
|
1181
|
+
const prio=[3,2,4,1,5,0,6].filter(c=>valid.includes(c));
|
|
1182
|
+
return prio[0];
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function play(col) {
|
|
1186
|
+
if(board[0][col]!==0) { msg='Column full!'; return; }
|
|
1187
|
+
const r=dropPiece(col,turn);
|
|
1188
|
+
if(checkWin(turn)) {
|
|
1189
|
+
msg=`Player ${turn} wins!`; score=turn===1?100:0; state='gameover';
|
|
1190
|
+
em.emit('gameover',score); return;
|
|
1191
|
+
}
|
|
1192
|
+
if(board.every(row=>row.every(c=>c!==0))) { msg='Draw!'; state='gameover'; em.emit('gameover',0); return; }
|
|
1193
|
+
turn=turn===1?2:1;
|
|
1194
|
+
if(aiMode&&turn===2) {
|
|
1195
|
+
const ac=aiMove();
|
|
1196
|
+
if(ac!==undefined) {
|
|
1197
|
+
const ar=dropPiece(ac,2);
|
|
1198
|
+
if(checkWin(2)) { msg='AI wins!'; state='gameover'; em.emit('gameover',0); return; }
|
|
1199
|
+
turn=1;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
msg='';
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function input(key) {
|
|
1206
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
1207
|
+
if(state!=='playing') return;
|
|
1208
|
+
if(key==='left') curCol=Math.max(0,curCol-1);
|
|
1209
|
+
if(key==='right') curCol=Math.min(COLS-1,curCol+1);
|
|
1210
|
+
if(key==='space'||key==='down'||key==='enter') play(curCol);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function frame() {
|
|
1214
|
+
const W=COLS*4+4,H=ROWS+6;
|
|
1215
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1216
|
+
cv.put(0,0,` CONNECT FOUR ${aiMode?'vs AI':'2P'} [←→ column SPACE/↓ drop]`,FC.YEL,BC.blk);
|
|
1217
|
+
// cursor
|
|
1218
|
+
cv.put(curCol*4+2,1,'▼',turn===1?FC.RED:FC.YEL,BC.blk);
|
|
1219
|
+
// board
|
|
1220
|
+
cv.put(0,2,'┌'+'───┬'.repeat(COLS-1)+'───┐',FC.wht,BC.blk);
|
|
1221
|
+
for(let r=0;r<ROWS;r++) {
|
|
1222
|
+
let row='│';
|
|
1223
|
+
for(let c=0;c<COLS;c++) {
|
|
1224
|
+
const v=board[r][c];
|
|
1225
|
+
const ch=v===0?' ○ ':v===1?' ● ':' ◉ ';
|
|
1226
|
+
const col=v===0?FC.BLK:v===1?FC.RED:FC.YEL;
|
|
1227
|
+
cv.put(row.length,r*2+3,ch,col,BC.blk); row+=ch+'│';
|
|
1228
|
+
}
|
|
1229
|
+
cv.put(0,r*2+3,'│',FC.wht,BC.blk); cv.put(W-1,r*2+3,'│',FC.wht,BC.blk);
|
|
1230
|
+
if(r<ROWS-1) cv.put(0,r*2+4,'├'+'───┼'.repeat(COLS-1)+'───┤',FC.wht,BC.blk);
|
|
1231
|
+
}
|
|
1232
|
+
cv.put(0,ROWS*2+3,'└'+'───┴'.repeat(COLS-1)+'───┘',FC.wht,BC.blk);
|
|
1233
|
+
if(msg) cv.put(1,ROWS*2+4,' '+msg.padEnd(W-2,' ')+'',FC.WHT,BC.blk);
|
|
1234
|
+
if(state==='gameover') cv.put(2,ROWS*2+5,'ENTER to play again',FC.GRN,BC.blk);
|
|
1235
|
+
return cv.render();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return { start, input, tick(){}, frame,
|
|
1239
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1240
|
+
get state(){return state;}, get score(){return score;} };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ─── GAME: BATTLESHIP ────────────────────────────────────────────────────────
|
|
1244
|
+
|
|
1245
|
+
function makeBattleship(opts = {}) {
|
|
1246
|
+
const SZ=10;
|
|
1247
|
+
const SHIPS=[{n:'Carrier',sz:5},{n:'Battleship',sz:4},{n:'Cruiser',sz:3},{n:'Submarine',sz:3},{n:'Destroyer',sz:2}];
|
|
1248
|
+
const em=makeEmitter();
|
|
1249
|
+
let pBoard,pHits,aBoard,aHits,aCursor,pCursor,state,score,msg,phase,aHunt,aTargets;
|
|
1250
|
+
|
|
1251
|
+
function makeBoard(){return new Array(SZ*SZ).fill(0);}
|
|
1252
|
+
function placeShips(board){
|
|
1253
|
+
for(const s of SHIPS){
|
|
1254
|
+
let placed=false;
|
|
1255
|
+
while(!placed){
|
|
1256
|
+
const horiz=Math.random()<0.5;
|
|
1257
|
+
const x=rng(horiz?SZ-s.sz+1:SZ);
|
|
1258
|
+
const y=rng(horiz?SZ:SZ-s.sz+1);
|
|
1259
|
+
let ok=true;
|
|
1260
|
+
for(let i=0;i<s.sz;i++){
|
|
1261
|
+
const nx=x+(horiz?i:0),ny=y+(horiz?0:i);
|
|
1262
|
+
if(board[ny*SZ+nx]){ok=false;break;}
|
|
1263
|
+
}
|
|
1264
|
+
if(ok){for(let i=0;i<s.sz;i++){const nx=x+(horiz?i:0),ny=y+(horiz?0:i);board[ny*SZ+nx]=1;}placed=true;}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function start(){
|
|
1270
|
+
pBoard=makeBoard(); placeShips(pBoard);
|
|
1271
|
+
aBoard=makeBoard(); placeShips(aBoard);
|
|
1272
|
+
pHits=new Array(SZ*SZ).fill(0); aHits=new Array(SZ*SZ).fill(0);
|
|
1273
|
+
pCursor=[0,0]; aCursor=[0,0];
|
|
1274
|
+
aHunt=[]; aTargets=[]; score=0; state='playing'; msg='Your turn! [Arrows aim, SPACE fire]';
|
|
1275
|
+
em.emit('start');
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function aiShoot(){
|
|
1279
|
+
let x,y;
|
|
1280
|
+
if(aTargets.length){
|
|
1281
|
+
[x,y]=aTargets.shift();
|
|
1282
|
+
} else {
|
|
1283
|
+
if(!aHunt.length){
|
|
1284
|
+
for(let i=0;i<SZ*SZ;i++) aHunt.push([i%SZ,i/SZ|0]);
|
|
1285
|
+
aHunt=shuffle(aHunt);
|
|
1286
|
+
}
|
|
1287
|
+
[x,y]=aHunt.shift();
|
|
1288
|
+
}
|
|
1289
|
+
aCursor=[x,y];
|
|
1290
|
+
if(aHits[y*SZ+x]) return aiShoot();
|
|
1291
|
+
aHits[y*SZ+x]=pBoard[y*SZ+x]?2:1;
|
|
1292
|
+
if(pBoard[y*SZ+x]){
|
|
1293
|
+
msg='AI hit your ship!';
|
|
1294
|
+
[[x-1,y],[x+1,y],[x,y-1],[x,y+1]].filter(([nx,ny])=>nx>=0&&nx<SZ&&ny>=0&&ny<SZ&&!aHits[ny*SZ+nx])
|
|
1295
|
+
.forEach(p=>aTargets.push(p));
|
|
1296
|
+
} else msg='AI missed.';
|
|
1297
|
+
if(aHits.filter((v,i)=>v===2&&pBoard[i]).length===SHIPS.reduce((s,sh)=>s+sh.sz,0)){
|
|
1298
|
+
state='gameover'; msg='AI wins!'; em.emit('gameover',0);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function input(key){
|
|
1303
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
1304
|
+
if(state!=='playing') return;
|
|
1305
|
+
const [cx,cy]=pCursor;
|
|
1306
|
+
if(key==='up') pCursor=[cx,Math.max(0,cy-1)];
|
|
1307
|
+
if(key==='down') pCursor=[cx,Math.min(SZ-1,cy+1)];
|
|
1308
|
+
if(key==='left') pCursor=[Math.max(0,cx-1),cy];
|
|
1309
|
+
if(key==='right') pCursor=[Math.min(SZ-1,cx+1),cy];
|
|
1310
|
+
if(key==='space'||key==='enter'){
|
|
1311
|
+
const [px,py]=pCursor;
|
|
1312
|
+
if(pHits[py*SZ+px]){msg='Already fired there!';return;}
|
|
1313
|
+
pHits[py*SZ+px]=aBoard[py*SZ+px]?2:1;
|
|
1314
|
+
if(aBoard[py*SZ+px]){
|
|
1315
|
+
score+=50; msg='Hit!';
|
|
1316
|
+
if(pHits.filter((v,i)=>v===2&&aBoard[i]).length===SHIPS.reduce((s,sh)=>s+sh.sz,0)){
|
|
1317
|
+
state='won'; msg='You win!'; em.emit('win',score); return;
|
|
1318
|
+
}
|
|
1319
|
+
} else msg='Miss.';
|
|
1320
|
+
aiShoot();
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function renderBoard(cv,ox,oy,board,hits,cursor,showShips,label){
|
|
1325
|
+
cv.put(ox,oy,label,FC.CYN,BC.blk);
|
|
1326
|
+
for(let y=0;y<SZ;y++) for(let x=0;x<SZ;x++){
|
|
1327
|
+
const h=hits[y*SZ+x];
|
|
1328
|
+
const isCur=cursor&&cursor[0]===x&&cursor[1]===y;
|
|
1329
|
+
const ship=board[y*SZ+x];
|
|
1330
|
+
let ch='·',col=FC.BLK;
|
|
1331
|
+
if(h===2){ch='✸';col=FC.RED;}
|
|
1332
|
+
else if(h===1){ch='○';col=FC.BLU;}
|
|
1333
|
+
else if(showShips&&ship){ch='▓';col=FC.GRN;}
|
|
1334
|
+
if(isCur&&!h){ch='◎';col=FC.YEL;}
|
|
1335
|
+
cv.set(ox+x*2,oy+y+1,ch,col,BC.blk);
|
|
1336
|
+
cv.set(ox+x*2+1,oy+y+1,' ',FC.wht,BC.blk);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function frame(){
|
|
1341
|
+
const W=50,H=18;
|
|
1342
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1343
|
+
cv.put(0,0,` BATTLESHIP Score:${score} [Arrows aim SPACE fire]`,FC.YEL,BC.blk);
|
|
1344
|
+
renderBoard(cv,1,1,aBoard,pHits,pCursor,false,'ENEMY WATERS:');
|
|
1345
|
+
renderBoard(cv,26,1,pBoard,aHits,aCursor,true,'YOUR FLEET:');
|
|
1346
|
+
cv.put(0,H-2,(' '+msg).padEnd(W,' '),FC.WHT,BC.blk);
|
|
1347
|
+
if(state==='gameover'||state==='won') cv.put(0,H-1,' ENTER to play again',FC.GRN,BC.blk);
|
|
1348
|
+
return cv.render();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return { start, input, tick(){}, frame,
|
|
1352
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1353
|
+
get state(){return state;}, get score(){return score;} };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ─── GAME: MAZE ──────────────────────────────────────────────────────────────
|
|
1357
|
+
|
|
1358
|
+
function makeMaze(opts = {}) {
|
|
1359
|
+
const MW=opts.w??21,MH=opts.h??21;
|
|
1360
|
+
const em=makeEmitter();
|
|
1361
|
+
let maze,px,py,steps,state,score,startTime;
|
|
1362
|
+
|
|
1363
|
+
function generate(){
|
|
1364
|
+
// 2D boolean grid: true = wall
|
|
1365
|
+
const m=Array.from({length:MH},()=>new Array(MW).fill(true));
|
|
1366
|
+
// recursive backtracker (odd coords = cells, even = walls)
|
|
1367
|
+
function carve(x,y){
|
|
1368
|
+
m[y][x]=false;
|
|
1369
|
+
const dirs=shuffle([[0,-2],[0,2],[-2,0],[2,0]]);
|
|
1370
|
+
for(const [dx,dy] of dirs){
|
|
1371
|
+
const nx=x+dx,ny=y+dy;
|
|
1372
|
+
if(nx>0&&nx<MW-1&&ny>0&&ny<MH-1&&m[ny][nx]){
|
|
1373
|
+
m[y+dy/2|0][x+dx/2|0]=false;
|
|
1374
|
+
carve(nx,ny);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
carve(1,1); return m;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function start(){
|
|
1382
|
+
maze=generate(); px=1; py=1; steps=0; score=0;
|
|
1383
|
+
maze[MH-2][MW-2]=false; // exit always open
|
|
1384
|
+
state='playing'; startTime=Date.now();
|
|
1385
|
+
em.emit('start');
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function input(key){
|
|
1389
|
+
if(state==='won'&&key==='enter'){start();return;}
|
|
1390
|
+
if(state!=='playing') return;
|
|
1391
|
+
const moves={up:[0,-1],down:[0,1],left:[-1,0],right:[1,0]};
|
|
1392
|
+
const m=moves[key]; if(!m) return;
|
|
1393
|
+
const nx=px+m[0],ny=py+m[1];
|
|
1394
|
+
if(nx>=0&&nx<MW&&ny>=0&&ny<MH&&!maze[ny][nx]){ px=nx; py=ny; steps++; }
|
|
1395
|
+
if(px===MW-2&&py===MH-2){
|
|
1396
|
+
const t=Math.max(1,(Date.now()-startTime)/1000|0);
|
|
1397
|
+
score=Math.max(10,500-steps-t); state='won'; em.emit('win',score);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function frame(){
|
|
1402
|
+
const cv=makeCanvas(MW+24,MH+3); cv.clear(' ',FC.wht,BC.blk);
|
|
1403
|
+
cv.put(0,0,` MAZE Steps:${steps} [Arrows to navigate reach ★]`,FC.YEL,BC.blk);
|
|
1404
|
+
for(let y=0;y<MH;y++) for(let x=0;x<MW;x++){
|
|
1405
|
+
if(x===px&&y===py) cv.set(x,y+2,'@',FC.YEL,BC.blk);
|
|
1406
|
+
else if(x===MW-2&&y===MH-2) cv.set(x,y+2,'★',FC.GRN,BC.blk);
|
|
1407
|
+
else cv.set(x,y+2,maze[y][x]?'█':' ',maze[y][x]?FC.BLU:FC.wht,BC.blk);
|
|
1408
|
+
}
|
|
1409
|
+
cv.put(MW+2,2,`Player: ${px},${py}`,FC.CYN,BC.blk);
|
|
1410
|
+
cv.put(MW+2,3,`Exit: ${MW-2},${MH-2}`,FC.CYN,BC.blk);
|
|
1411
|
+
if(state==='won'){cv.put(MW+2,5,'YOU MADE IT!',FC.GRN,BC.blk);cv.put(MW+2,6,`Score: ${score}`,FC.GRN,BC.blk);cv.put(MW+2,7,'ENTER new maze',FC.GRN,BC.blk);}
|
|
1412
|
+
return cv.render();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return { start, input, tick(){}, frame,
|
|
1416
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1417
|
+
get state(){return state;}, get score(){return score;} };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ─── GAME: LIGHTS OUT ────────────────────────────────────────────────────────
|
|
1421
|
+
|
|
1422
|
+
function makeLightsOut(opts = {}) {
|
|
1423
|
+
const SZ=opts.size??5, SCRAMBLE=opts.scramble??15;
|
|
1424
|
+
const em=makeEmitter();
|
|
1425
|
+
let grid,cx,cy,moves,state,score;
|
|
1426
|
+
|
|
1427
|
+
function toggle(gx,gy){
|
|
1428
|
+
[[0,0],[0,-1],[0,1],[-1,0],[1,0]].forEach(([dx,dy])=>{
|
|
1429
|
+
const nx=gx+dx,ny=gy+dy;
|
|
1430
|
+
if(nx>=0&&nx<SZ&&ny>=0&&ny<SZ) grid[ny*SZ+nx]^=1;
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function start(){
|
|
1435
|
+
grid=new Array(SZ*SZ).fill(0);
|
|
1436
|
+
for(let i=0;i<SCRAMBLE;i++) toggle(rng(SZ),rng(SZ));
|
|
1437
|
+
cx=SZ>>1; cy=SZ>>1; moves=0; score=0; state='playing';
|
|
1438
|
+
em.emit('start');
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function input(key){
|
|
1442
|
+
if(state==='won'&&key==='enter'){start();return;}
|
|
1443
|
+
if(state!=='playing') return;
|
|
1444
|
+
if(key==='up') cy=Math.max(0,cy-1);
|
|
1445
|
+
if(key==='down') cy=Math.min(SZ-1,cy+1);
|
|
1446
|
+
if(key==='left') cx=Math.max(0,cx-1);
|
|
1447
|
+
if(key==='right') cx=Math.min(SZ-1,cx+1);
|
|
1448
|
+
if(key==='space'||key==='enter'){
|
|
1449
|
+
toggle(cx,cy); moves++;
|
|
1450
|
+
if(grid.every(v=>v===0)){
|
|
1451
|
+
score=Math.max(10,500-moves*10); state='won'; em.emit('win',score);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function frame(){
|
|
1457
|
+
const W=SZ*6+4,H=SZ*3+6;
|
|
1458
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1459
|
+
cv.put(0,0,` LIGHTS OUT Moves:${moves} [Arrows SPACE toggle — turn all lights off]`,FC.YEL,BC.blk);
|
|
1460
|
+
for(let y=0;y<SZ;y++) for(let x=0;x<SZ;x++){
|
|
1461
|
+
const on=grid[y*SZ+x];
|
|
1462
|
+
const isCur=x===cx&&y===cy;
|
|
1463
|
+
const bx=x*6+1,by=y*3+2;
|
|
1464
|
+
const bcol=on?BC.YEL:BC.blk, fcol=on?FC.blk:FC.BLK;
|
|
1465
|
+
cv.fill(bx,by,5,2,on?'█':' ',fcol,bcol);
|
|
1466
|
+
if(isCur) cv.put(bx+1,by+0,' ◉ ',FC.WHT,bcol);
|
|
1467
|
+
}
|
|
1468
|
+
cv.put(1,H-2,`Score: ${score}`,FC.CYN,BC.blk);
|
|
1469
|
+
if(state==='won'){cv.put(1,H-1,' ALL OFF! ENTER new puzzle ',FC.WHT,BC.grn);}
|
|
1470
|
+
return cv.render();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
return { start, input, tick(){}, frame,
|
|
1474
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1475
|
+
get state(){return state;}, get score(){return score;} };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ─── GAME: SUDOKU ────────────────────────────────────────────────────────────
|
|
1479
|
+
|
|
1480
|
+
function makeSudoku(opts = {}) {
|
|
1481
|
+
const em=makeEmitter();
|
|
1482
|
+
let puzzle,board,given,cx,cy,state,score,errors,puzzleIdx;
|
|
1483
|
+
|
|
1484
|
+
function parsePuzzle(str){
|
|
1485
|
+
return Array.from(str).map(c=>c==='.'?0:+c);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function start(){
|
|
1489
|
+
puzzleIdx=(puzzleIdx??-1)+1; if(puzzleIdx>=SUDOKU_PUZZLES.length) puzzleIdx=0;
|
|
1490
|
+
puzzle=parsePuzzle(SUDOKU_PUZZLES[puzzleIdx]);
|
|
1491
|
+
board=[...puzzle]; given=puzzle.map(v=>v!==0);
|
|
1492
|
+
cx=0; cy=0; errors=0; score=0; state='playing';
|
|
1493
|
+
em.emit('start');
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function isValid(b,pos,val){
|
|
1497
|
+
const r=pos/9|0,c=pos%9;
|
|
1498
|
+
for(let i=0;i<9;i++){
|
|
1499
|
+
if(i!==c&&b[r*9+i]===val) return false;
|
|
1500
|
+
if(i!==r&&b[i*9+c]===val) return false;
|
|
1501
|
+
}
|
|
1502
|
+
const br=r/3|0*3,bc=c/3|0*3;
|
|
1503
|
+
for(let dr=0;dr<3;dr++) for(let dc=0;dc<3;dc++){
|
|
1504
|
+
const ni=(br+dr)*9+(bc+dc);
|
|
1505
|
+
if(ni!==pos&&b[ni]===val) return false;
|
|
1506
|
+
}
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function input(key){
|
|
1511
|
+
if(state==='won'&&key==='enter'){start();return;}
|
|
1512
|
+
if(state!=='playing') return;
|
|
1513
|
+
if(key==='up') cy=Math.max(0,cy-1);
|
|
1514
|
+
if(key==='down') cy=Math.min(8,cy+1);
|
|
1515
|
+
if(key==='left') cx=Math.max(0,cx-1);
|
|
1516
|
+
if(key==='right') cx=Math.min(8,cx+1);
|
|
1517
|
+
const pos=cy*9+cx;
|
|
1518
|
+
if(/^[1-9]$/.test(key)&&!given[pos]){
|
|
1519
|
+
const v=+key;
|
|
1520
|
+
if(!isValid(board,pos,v)) errors++;
|
|
1521
|
+
board[pos]=v;
|
|
1522
|
+
if(board.every((v,i)=>v!==0&&isValid(board,i,v))){
|
|
1523
|
+
score=Math.max(10,1000-errors*50); state='won'; em.emit('win',score);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if((key==='delete'||key==='backspace'||key==='0')&&!given[pos]) board[pos]=0;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function frame(){
|
|
1530
|
+
const W=42,H=22;
|
|
1531
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1532
|
+
cv.put(0,0,` SUDOKU Errors:${errors} [Arrows move, 1-9 enter, DEL clear]`,FC.YEL,BC.blk);
|
|
1533
|
+
// grid
|
|
1534
|
+
for(let r=0;r<9;r++){
|
|
1535
|
+
for(let c=0;c<9;c++){
|
|
1536
|
+
const pos=r*9+c;
|
|
1537
|
+
const v=board[pos];
|
|
1538
|
+
const isCur=c===cx&&r===cy;
|
|
1539
|
+
const isGiv=given[pos];
|
|
1540
|
+
const bad=v&&!isValid(board,pos,v);
|
|
1541
|
+
const ch=v?String(v):'.';
|
|
1542
|
+
const fg=bad?FC.RED:isGiv?FC.WHT:FC.CYN;
|
|
1543
|
+
const bg=isCur?BC.BLU:BC.blk;
|
|
1544
|
+
cv.put(c*4+(c>5?2:c>2?1:0)+1, r*2+(r>5?2:r>2?1:0)+2, ` ${ch} `, fg, bg);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// grid lines
|
|
1548
|
+
for(let i=0;i<3;i++) {
|
|
1549
|
+
const row=i*6+1; const col_x=i*12+4;
|
|
1550
|
+
cv.put(0,row+2,'─'.repeat(38),FC.BLK,BC.blk);
|
|
1551
|
+
for(let r=0;r<10;r++) cv.set(col_x,r*2+2,'│',FC.BLK,BC.blk);
|
|
1552
|
+
}
|
|
1553
|
+
cv.put(1,H-2,`Score: ${score} Puzzle: ${puzzleIdx+1}/${SUDOKU_PUZZLES.length}`,FC.CYN,BC.blk);
|
|
1554
|
+
if(state==='won') cv.put(1,H-1,' SOLVED! ENTER next puzzle ',FC.WHT,BC.grn);
|
|
1555
|
+
return cv.render();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
return { start, input, tick(){}, frame,
|
|
1559
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1560
|
+
get state(){return state;}, get score(){return score;} };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ─── GAME: BLACKJACK ─────────────────────────────────────────────────────────
|
|
1564
|
+
|
|
1565
|
+
function makeBlackjack(opts = {}) {
|
|
1566
|
+
const em=makeEmitter();
|
|
1567
|
+
let deck,pHand,dHand,chips,bet,state,score,msg;
|
|
1568
|
+
|
|
1569
|
+
const SUITS=['♠','♥','♦','♣'],RANKS=['A','2','3','4','5','6','7','8','9','10','J','Q','K'];
|
|
1570
|
+
const SUIT_COL={'♠':FC.WHT,'♥':FC.RED,'♦':FC.RED,'♣':FC.WHT};
|
|
1571
|
+
|
|
1572
|
+
function makeDeck(){
|
|
1573
|
+
const d=[];
|
|
1574
|
+
for(const s of SUITS) for(const r of RANKS) d.push({r,s});
|
|
1575
|
+
return shuffle(d);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function cardVal(r){ return r==='A'?11:['J','Q','K'].includes(r)?10:+r; }
|
|
1579
|
+
function handVal(h){
|
|
1580
|
+
let v=h.reduce((s,c)=>s+cardVal(c.r),0), aces=h.filter(c=>c.r==='A').length;
|
|
1581
|
+
while(v>21&&aces>0){v-=10;aces--;}
|
|
1582
|
+
return v;
|
|
1583
|
+
}
|
|
1584
|
+
function deal(){return deck.pop()??makeDeck().pop();}
|
|
1585
|
+
|
|
1586
|
+
function start(){
|
|
1587
|
+
deck=makeDeck(); chips=opts.chips??100; bet=10; state='idle'; msg='Place your bet with +/- then ENTER to deal';
|
|
1588
|
+
em.emit('start');
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function startRound(){
|
|
1592
|
+
if(chips<=0){state='gameover';msg='Broke! Game over.';em.emit('gameover',chips);return;}
|
|
1593
|
+
if(bet>chips) bet=chips;
|
|
1594
|
+
pHand=[deal(),deal()]; dHand=[deal(),deal()];
|
|
1595
|
+
state='playing'; msg='H=hit S=stand D=double ENTER=stand';
|
|
1596
|
+
if(handVal(pHand)===21){stand();}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function stand(){
|
|
1600
|
+
while(handVal(dHand)<17) dHand.push(deal());
|
|
1601
|
+
const pv=handVal(pHand),dv=handVal(dHand);
|
|
1602
|
+
if(dv>21||pv>dv){chips+=bet;msg=`You win! +${bet} (${pv} vs ${dv})`;}
|
|
1603
|
+
else if(pv===dv){msg=`Push! Tie (${pv} vs ${dv})`;}
|
|
1604
|
+
else{chips-=bet;msg=`You lose! -${bet} (${pv} vs ${dv})`;}
|
|
1605
|
+
score=chips; state='roundover'; em.emit('score',score);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function input(key){
|
|
1609
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
1610
|
+
if(state==='idle'){
|
|
1611
|
+
if(key==='+') bet=Math.min(chips,bet+10);
|
|
1612
|
+
if(key==='-') bet=Math.max(5,bet-10);
|
|
1613
|
+
if(key==='enter'||key==='space') startRound();
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if(state==='roundover'){
|
|
1617
|
+
if(key==='enter'||key==='space'){ state='idle'; msg='Place your bet with +/- then ENTER to deal'; }
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if(state!=='playing') return;
|
|
1621
|
+
if(key==='h'||key==='up'){
|
|
1622
|
+
pHand.push(deal());
|
|
1623
|
+
if(handVal(pHand)>21){chips-=bet;msg=`Bust! -${bet}`;score=chips;state='roundover';em.emit('score',score);}
|
|
1624
|
+
}
|
|
1625
|
+
if(key==='s'||key==='enter'||key==='down') stand();
|
|
1626
|
+
if(key==='d'){
|
|
1627
|
+
bet=Math.min(bet*2,chips); pHand.push(deal());
|
|
1628
|
+
if(handVal(pHand)>21){chips-=bet;msg=`Double bust! -${bet}`;score=chips;state='roundover';}
|
|
1629
|
+
else stand();
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function cardStr(c,hidden=false){
|
|
1634
|
+
return hidden?'[??]':`[${c.r}${c.s}]`;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function frame(){
|
|
1638
|
+
const W=50,H=18;
|
|
1639
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1640
|
+
cv.put(0,0,` BLACKJACK Chips:${chips} Bet:${bet} [H=hit S=stand D=double +/- bet]`,FC.YEL,BC.blk);
|
|
1641
|
+
cv.put(1,2,'DEALER: ',FC.CYN,BC.blk);
|
|
1642
|
+
let dx=9;
|
|
1643
|
+
const showAll=state==='roundover'||state==='gameover';
|
|
1644
|
+
for(let i=0;i<dHand.length;i++){
|
|
1645
|
+
const c=dHand[i], hidden=!showAll&&i===1;
|
|
1646
|
+
const cs=cardStr(c,hidden);
|
|
1647
|
+
cv.put(dx,2,cs,hidden?FC.BLK:SUIT_COL[c.s],BC.blk); dx+=cs.length+1;
|
|
1648
|
+
}
|
|
1649
|
+
if(showAll) cv.put(dx+1,2,`= ${handVal(dHand)}`,FC.CYN,BC.blk);
|
|
1650
|
+
cv.put(1,5,'PLAYER: ',FC.CYN,BC.blk);
|
|
1651
|
+
let px2=9;
|
|
1652
|
+
for(const c of pHand){ const cs=cardStr(c); cv.put(px2,5,cs,SUIT_COL[c.s],BC.blk); px2+=cs.length+1; }
|
|
1653
|
+
cv.put(px2+1,5,`= ${handVal(pHand)}`,FC.GRN,BC.blk);
|
|
1654
|
+
cv.put(1,8,msg.padEnd(W-2,' '),FC.WHT,BC.blk);
|
|
1655
|
+
if(state==='gameover') cv.put(1,10,'ENTER to restart',FC.RED,BC.blk);
|
|
1656
|
+
return cv.render();
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
return { start, input, tick(){}, frame,
|
|
1660
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1661
|
+
get state(){return state;}, get score(){return score??chips;} };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ─── GAME: SLOTS ─────────────────────────────────────────────────────────────
|
|
1665
|
+
|
|
1666
|
+
function makeSlots(opts = {}) {
|
|
1667
|
+
const em=makeEmitter();
|
|
1668
|
+
const SYMS=[' 7 ','BAR','BEL','CHR','LMN','ORG','PLM'];
|
|
1669
|
+
const SYM_COL=[FC.YEL,FC.WHT,FC.YEL,FC.RED,FC.YEL,FC.YEL,FC.MAG];
|
|
1670
|
+
const WEIGHTS=[1,2,3,4,4,3,3];
|
|
1671
|
+
const PAY={'7 7 7':500,'BARBAR':200,'BELBEL':100,'CHRCHR':50,'LMNLMN':30,'ORGORG':30,'PLMPLM':20};
|
|
1672
|
+
let reels,spinning,spinFrames,result,credits,bet,state,score,msg;
|
|
1673
|
+
|
|
1674
|
+
function pickSym(){
|
|
1675
|
+
const total=WEIGHTS.reduce((a,b)=>a+b,0);
|
|
1676
|
+
let r=rng(total),i=0;
|
|
1677
|
+
for(;i<WEIGHTS.length;i++){r-=WEIGHTS[i];if(r<0)break;}
|
|
1678
|
+
return SYMS[i]??SYMS[0];
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function start(){
|
|
1682
|
+
reels=[[pickSym(),pickSym(),pickSym()],[pickSym(),pickSym(),pickSym()],[pickSym(),pickSym(),pickSym()]];
|
|
1683
|
+
credits=opts.credits??100; bet=10; spinning=false; spinFrames=0;
|
|
1684
|
+
state='idle'; score=credits; msg='SPACE to spin +/- adjust bet';
|
|
1685
|
+
em.emit('start');
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function spin(){
|
|
1689
|
+
if(credits<bet){msg='Not enough credits!';return;}
|
|
1690
|
+
credits-=bet; spinning=true; spinFrames=20; msg='Spinning...';
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function evalResult(){
|
|
1694
|
+
const r0=reels[0][1],r1=reels[1][1],r2=reels[2][1];
|
|
1695
|
+
const line=r0.trim()+r1.trim()+r2.trim();
|
|
1696
|
+
const key=Object.keys(PAY).find(k=>line.startsWith(k));
|
|
1697
|
+
if(key){const w=PAY[key]*bet/10|0;credits+=w;msg=`${key}! Win ${w}!`;}
|
|
1698
|
+
else if(r0.trim()==='CHR'){credits+=bet/2|0;msg=`Cherry! Win ${bet/2|0}`;}
|
|
1699
|
+
else msg='No win. Try again!';
|
|
1700
|
+
score=credits; em.emit('score',credits);
|
|
1701
|
+
if(credits<=0){state='gameover';em.emit('gameover',0);}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function input(key){
|
|
1705
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
1706
|
+
if(spinning) return;
|
|
1707
|
+
if(key==='space') spin();
|
|
1708
|
+
if(key==='+'||key==='up') bet=Math.min(credits,bet+5);
|
|
1709
|
+
if(key==='-'||key==='down') bet=Math.max(5,bet-5);
|
|
1710
|
+
if(key==='enter') spin();
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function tick(dt=16){
|
|
1714
|
+
if(!spinning) return;
|
|
1715
|
+
spinFrames--;
|
|
1716
|
+
if(spinFrames>0){
|
|
1717
|
+
for(let i=0;i<3;i++){
|
|
1718
|
+
if(spinFrames>(i*5)){
|
|
1719
|
+
reels[i]=[pickSym(),pickSym(),pickSym()];
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
} else {
|
|
1723
|
+
spinning=false; evalResult();
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function frame(){
|
|
1728
|
+
const W=40,H=14;
|
|
1729
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1730
|
+
cv.put(0,0,` SLOTS Credits:${credits} Bet:${bet} [SPACE spin +/- bet]`,FC.YEL,BC.blk);
|
|
1731
|
+
// machine frame
|
|
1732
|
+
cv.box(4,2,32,9,FC.WHT,BC.blk);
|
|
1733
|
+
// three reels
|
|
1734
|
+
for(let reel=0;reel<3;reel++){
|
|
1735
|
+
const rx=reel*10+5;
|
|
1736
|
+
for(let row=0;row<3;row++){
|
|
1737
|
+
const sym=reels[reel][row];
|
|
1738
|
+
const idx=SYMS.indexOf(sym);
|
|
1739
|
+
const col=SYM_COL[idx]??FC.wht;
|
|
1740
|
+
const hl=row===1;
|
|
1741
|
+
cv.put(rx,row+3,sym,hl?col:FC.BLK,hl?BC.blk:BC.blk);
|
|
1742
|
+
}
|
|
1743
|
+
if(reel<2) for(let y=3;y<6;y++) cv.set(rx+3,y,'│',FC.BLK,BC.blk);
|
|
1744
|
+
}
|
|
1745
|
+
// payline indicator
|
|
1746
|
+
cv.put(4,4,'►',FC.RED,BC.blk); cv.put(35,4,'◄',FC.RED,BC.blk);
|
|
1747
|
+
cv.put(2,H-3,msg.padEnd(W-4,' '),FC.WHT,BC.blk);
|
|
1748
|
+
if(state==='gameover') cv.put(2,H-2,' BROKE! ENTER restart ',FC.WHT,BC.red);
|
|
1749
|
+
return cv.render();
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
return { start, input, tick, frame,
|
|
1753
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1754
|
+
get state(){return state;}, get score(){return score??credits;} };
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// ─── GAME: VIDEO POKER ───────────────────────────────────────────────────────
|
|
1758
|
+
|
|
1759
|
+
function makeVideoPoker(opts = {}) {
|
|
1760
|
+
const em=makeEmitter();
|
|
1761
|
+
const SUITS=['♠','♥','♦','♣'],RANKS=['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
|
|
1762
|
+
const SUIT_COL={'♠':FC.WHT,'♥':FC.RED,'♦':FC.RED,'♣':FC.WHT};
|
|
1763
|
+
const HANDS=[
|
|
1764
|
+
['Royal Flush',800],['Straight Flush',50],['Four of a Kind',25],
|
|
1765
|
+
['Full House',9],['Flush',6],['Straight',4],
|
|
1766
|
+
['Three of a Kind',3],['Two Pair',2],['Jacks or Better',1],['Nothing',0],
|
|
1767
|
+
];
|
|
1768
|
+
let deck,hand,held,credits,bet,state,score,msg,phase;
|
|
1769
|
+
|
|
1770
|
+
function makeDeck(){ return shuffle(SUITS.flatMap(s=>RANKS.map(r=>({r,s})))); }
|
|
1771
|
+
function deal(){ return deck.pop()??makeDeck().pop(); }
|
|
1772
|
+
|
|
1773
|
+
function evalHand(h){
|
|
1774
|
+
const ri=h.map(c=>RANKS.indexOf(c.r)).sort((a,b)=>a-b);
|
|
1775
|
+
const flush=h.every(c=>c.s===h[0].s);
|
|
1776
|
+
const straight=ri.every((v,i)=>i===0||v===ri[i-1]+1)||
|
|
1777
|
+
(ri.join(',')===('0,1,2,3,12')); // A-2-3-4-5
|
|
1778
|
+
const freq=Object.values(ri.reduce((m,v)=>{m[v]=(m[v]||0)+1;return m;},{})).sort((a,b)=>b-a);
|
|
1779
|
+
const pairs=freq.filter(f=>f===2).length;
|
|
1780
|
+
const hasJackOrBetter=pairs>=1&&h.some(c=>['J','Q','K','A'].includes(c.r)&&
|
|
1781
|
+
h.filter(x=>x.r===c.r).length===2);
|
|
1782
|
+
if(flush&&straight&&ri[4]===12&&ri[0]===8) return 'Royal Flush';
|
|
1783
|
+
if(flush&&straight) return 'Straight Flush';
|
|
1784
|
+
if(freq[0]===4) return 'Four of a Kind';
|
|
1785
|
+
if(freq[0]===3&&freq[1]===2) return 'Full House';
|
|
1786
|
+
if(flush) return 'Flush';
|
|
1787
|
+
if(straight) return 'Straight';
|
|
1788
|
+
if(freq[0]===3) return 'Three of a Kind';
|
|
1789
|
+
if(pairs===2) return 'Two Pair';
|
|
1790
|
+
if(hasJackOrBetter) return 'Jacks or Better';
|
|
1791
|
+
return 'Nothing';
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function start(){
|
|
1795
|
+
deck=makeDeck(); credits=opts.credits??100; bet=10;
|
|
1796
|
+
hand=[]; held=[false,false,false,false,false];
|
|
1797
|
+
state='idle'; score=credits; msg='ENTER to deal +/- adjust bet';
|
|
1798
|
+
em.emit('start');
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function startRound(){
|
|
1802
|
+
if(credits<bet){msg='Not enough credits!';return;}
|
|
1803
|
+
credits-=bet;
|
|
1804
|
+
hand=[deal(),deal(),deal(),deal(),deal()];
|
|
1805
|
+
held=[false,false,false,false,false];
|
|
1806
|
+
phase='hold'; state='playing'; msg='1-5 toggle hold ENTER draw';
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function draw(){
|
|
1810
|
+
for(let i=0;i<5;i++) if(!held[i]) hand[i]=deal();
|
|
1811
|
+
const hn=evalHand(hand);
|
|
1812
|
+
const [,mult]=HANDS.find(([n])=>n===hn)??['',0];
|
|
1813
|
+
const win=mult*bet;
|
|
1814
|
+
credits+=win; score=credits; em.emit('score',credits);
|
|
1815
|
+
msg=`${hn}${win?` — Win ${win}!`:''}`;
|
|
1816
|
+
phase='result'; state='roundover';
|
|
1817
|
+
if(credits<=0){state='gameover';em.emit('gameover',0);}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function input(key){
|
|
1821
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
1822
|
+
if(state==='roundover'&&key==='enter'){state='idle';msg='ENTER to deal +/- bet';return;}
|
|
1823
|
+
if(state==='idle'){
|
|
1824
|
+
if(key==='+') bet=Math.min(credits,bet+5);
|
|
1825
|
+
if(key==='-') bet=Math.max(5,bet-5);
|
|
1826
|
+
if(key==='enter'||key==='space') startRound();
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if(state!=='playing') return;
|
|
1830
|
+
if(/^[1-5]$/.test(key)) held[+key-1]=!held[+key-1];
|
|
1831
|
+
if(key==='enter'||key==='space') draw();
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function frame(){
|
|
1835
|
+
const W=50,H=18;
|
|
1836
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1837
|
+
cv.put(0,0,` VIDEO POKER (Jacks or Better) Credits:${credits} Bet:${bet}`,FC.YEL,BC.blk);
|
|
1838
|
+
// paytable
|
|
1839
|
+
const pOx=35;
|
|
1840
|
+
HANDS.forEach(([n,m],i)=>{
|
|
1841
|
+
cv.put(pOx,i+2,`${n}: ${m*bet}`,m?FC.YEL:FC.BLK,BC.blk);
|
|
1842
|
+
});
|
|
1843
|
+
// cards
|
|
1844
|
+
for(let i=0;i<5;i++){
|
|
1845
|
+
const cx2=i*7+1, cy2=3;
|
|
1846
|
+
if(hand[i]){
|
|
1847
|
+
cv.box(cx2,cy2,6,5,FC.wht,BC.blk);
|
|
1848
|
+
cv.put(cx2+1,cy2+1,`${hand[i].r}`,SUIT_COL[hand[i].s],BC.blk);
|
|
1849
|
+
cv.put(cx2+2,cy2+2,`${hand[i].s}`,SUIT_COL[hand[i].s],BC.blk);
|
|
1850
|
+
cv.put(cx2+1,cy2+3,held[i]?'HELD':' ',held[i]?FC.YEL:FC.BLK,BC.blk);
|
|
1851
|
+
} else {
|
|
1852
|
+
cv.box(cx2,cy2,6,5,FC.BLK,BC.blk);
|
|
1853
|
+
}
|
|
1854
|
+
cv.put(cx2+2,cy2+5,` ${i+1} `,FC.BLK,BC.blk);
|
|
1855
|
+
}
|
|
1856
|
+
cv.put(1,9,msg.padEnd(33,' '),FC.WHT,BC.blk);
|
|
1857
|
+
if(state==='idle') cv.put(1,10,'ENTER to deal',FC.GRN,BC.blk);
|
|
1858
|
+
if(state==='gameover') cv.put(1,10,' BROKE! ENTER restart ',FC.WHT,BC.red);
|
|
1859
|
+
return cv.render();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
return { start, input, tick(){}, frame,
|
|
1863
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1864
|
+
get state(){return state;}, get score(){return score??credits;} };
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// ─── GAME: HANGMAN ───────────────────────────────────────────────────────────
|
|
1868
|
+
|
|
1869
|
+
function makeHangman(opts = {}) {
|
|
1870
|
+
const em=makeEmitter();
|
|
1871
|
+
let word,guessed,wrong,state,score;
|
|
1872
|
+
|
|
1873
|
+
const GALLOWS=[
|
|
1874
|
+
[' ',' ',' ',' ',' ',' ',' '],
|
|
1875
|
+
[' ',' ',' ',' ',' ',' ','═════'],
|
|
1876
|
+
[' ╔ ',' ║ ',' ',' ',' ',' ','═════'],
|
|
1877
|
+
[' ╔══',' ║ ',' ',' ',' ',' ','═════'],
|
|
1878
|
+
[' ╔══',' ║ ',' ○ ',' ',' ',' ','═════'],
|
|
1879
|
+
[' ╔══',' ║ ',' ○ ',' │ ',' ',' ','═════'],
|
|
1880
|
+
[' ╔══',' ║ ',' ○ ',' /│ ',' ',' ','═════'],
|
|
1881
|
+
[' ╔══',' ║ ',' ○ ',' /│\\ ',' ',' ','═════'],
|
|
1882
|
+
[' ╔══',' ║ ',' ○ ',' /│\\ ',' / ',' ','═════'],
|
|
1883
|
+
[' ╔══',' ║ ',' ○ ',' /│\\ ',' / \\ ',' ','═════'],
|
|
1884
|
+
];
|
|
1885
|
+
|
|
1886
|
+
function start(){
|
|
1887
|
+
word=HANGMAN_WORDS[rng(HANGMAN_WORDS.length)].toUpperCase();
|
|
1888
|
+
guessed=new Set(); wrong=0; state='playing'; score=0;
|
|
1889
|
+
em.emit('start');
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function input(key){
|
|
1893
|
+
if((state==='gameover'||state==='won')&&key==='enter'){start();return;}
|
|
1894
|
+
if(state!=='playing') return;
|
|
1895
|
+
if(/^[a-z]$/.test(key)){
|
|
1896
|
+
const ch=key.toUpperCase();
|
|
1897
|
+
if(guessed.has(ch)) return;
|
|
1898
|
+
guessed.add(ch);
|
|
1899
|
+
if(!word.includes(ch)){
|
|
1900
|
+
wrong++; if(wrong>=9){state='gameover';em.emit('gameover',0);}
|
|
1901
|
+
} else {
|
|
1902
|
+
if([...word].every(c=>guessed.has(c))){
|
|
1903
|
+
score=Math.max(10,(9-wrong)*100); state='won'; em.emit('win',score);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function frame(){
|
|
1910
|
+
const W=50,H=14;
|
|
1911
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1912
|
+
cv.put(0,0,` HANGMAN Wrong:${wrong}/9 [a-z to guess ENTER restart]`,FC.YEL,BC.blk);
|
|
1913
|
+
// gallows
|
|
1914
|
+
const stage=GALLOWS[Math.min(wrong,9)];
|
|
1915
|
+
for(let i=0;i<stage.length;i++) cv.put(1,i+2,stage[i],FC.wht,BC.blk);
|
|
1916
|
+
// word display
|
|
1917
|
+
const display=[...word].map(c=>guessed.has(c)?c:'_').join(' ');
|
|
1918
|
+
cv.put(10,4,display,FC.CYN,BC.blk);
|
|
1919
|
+
// guessed letters
|
|
1920
|
+
const sorted=[...guessed].sort();
|
|
1921
|
+
cv.put(10,6,`Guessed: ${sorted.join(' ')}`,FC.BLK,BC.blk);
|
|
1922
|
+
if(state==='won') cv.put(10,8,` YOU WIN! The word was ${word} `,FC.WHT,BC.grn);
|
|
1923
|
+
if(state==='gameover') cv.put(10,8,` GAME OVER! Word: ${word} `,FC.WHT,BC.red);
|
|
1924
|
+
if(state!=='idle') cv.put(10,9,`Hint: ${word.length} letters`,FC.BLK,BC.blk);
|
|
1925
|
+
return cv.render();
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
return { start, input, tick(){}, frame,
|
|
1929
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1930
|
+
get state(){return state;}, get score(){return score;} };
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// ─── GAME: TYPING TEST ───────────────────────────────────────────────────────
|
|
1934
|
+
|
|
1935
|
+
function makeTypingTest(opts = {}) {
|
|
1936
|
+
const em=makeEmitter();
|
|
1937
|
+
let phrase,typed,startTime,endTime,wpm,accuracy,state,score,phraseIdx;
|
|
1938
|
+
|
|
1939
|
+
function start(){
|
|
1940
|
+
phraseIdx=rng(TYPING_PHRASES.length);
|
|
1941
|
+
phrase=TYPING_PHRASES[phraseIdx];
|
|
1942
|
+
typed=''; startTime=null; endTime=null; wpm=0; accuracy=100;
|
|
1943
|
+
state='playing'; score=0;
|
|
1944
|
+
em.emit('start');
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function input(key){
|
|
1948
|
+
if((state==='won'||state==='gameover')&&key==='enter'){start();return;}
|
|
1949
|
+
if(state!=='playing') return;
|
|
1950
|
+
if(!startTime) startTime=Date.now();
|
|
1951
|
+
if(key==='backspace'||key==='delete'){ typed=typed.slice(0,-1); return; }
|
|
1952
|
+
if(key.length===1) {
|
|
1953
|
+
typed+=key;
|
|
1954
|
+
if(typed.length===phrase.length){
|
|
1955
|
+
endTime=Date.now();
|
|
1956
|
+
const elapsed=(endTime-startTime)/60000;
|
|
1957
|
+
const words=phrase.split(' ').length;
|
|
1958
|
+
wpm=Math.round(words/elapsed);
|
|
1959
|
+
const correct=[...typed].filter((c,i)=>c===phrase[i]).length;
|
|
1960
|
+
accuracy=Math.round(correct/phrase.length*100);
|
|
1961
|
+
score=wpm*accuracy/100|0;
|
|
1962
|
+
state='won'; em.emit('win',score);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function frame(){
|
|
1968
|
+
const W=60,H=14;
|
|
1969
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
1970
|
+
const elapsed=startTime?((Date.now()-startTime)/1000).toFixed(1):'0.0';
|
|
1971
|
+
cv.put(0,0,` TYPING TEST Time:${elapsed}s WPM:${wpm} Accuracy:${accuracy}%`,FC.YEL,BC.blk);
|
|
1972
|
+
cv.put(1,2,'Type the phrase below:',FC.CYN,BC.blk);
|
|
1973
|
+
// wrap phrase at 56 chars
|
|
1974
|
+
const wrap=56;
|
|
1975
|
+
let py=4;
|
|
1976
|
+
for(let i=0;i<phrase.length;i+=wrap){
|
|
1977
|
+
const chunk=phrase.slice(i,i+wrap);
|
|
1978
|
+
for(let j=0;j<chunk.length;j++){
|
|
1979
|
+
const gi=i+j;
|
|
1980
|
+
let col=FC.BLK;
|
|
1981
|
+
if(gi<typed.length) col=typed[gi]===phrase[gi]?FC.GRN:FC.RED;
|
|
1982
|
+
cv.set(j+2,py,phrase[gi],col,BC.blk);
|
|
1983
|
+
}
|
|
1984
|
+
// show cursor
|
|
1985
|
+
if(typed.length>=i&&typed.length<i+wrap) cv.set(typed.length-i+2,py,'▌',FC.WHT,BC.blk);
|
|
1986
|
+
py++;
|
|
1987
|
+
}
|
|
1988
|
+
if(state==='won'){
|
|
1989
|
+
cv.put(1,py+1,` Done! WPM:${wpm} Accuracy:${accuracy}% Score:${score} ENTER again `,FC.WHT,BC.grn);
|
|
1990
|
+
} else {
|
|
1991
|
+
cv.put(1,py+1,' [Type above. Backspace to correct]',FC.BLK,BC.blk);
|
|
1992
|
+
}
|
|
1993
|
+
return cv.render();
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
return { start, input, tick(){}, frame,
|
|
1997
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
1998
|
+
get state(){return state;}, get score(){return score;} };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// ─── GAME: SIMON SAYS ────────────────────────────────────────────────────────
|
|
2002
|
+
|
|
2003
|
+
function makeSimon(opts = {}) {
|
|
2004
|
+
const em=makeEmitter();
|
|
2005
|
+
const COLORS=['RED','GRN','BLU','YEL'];
|
|
2006
|
+
const FC_MAP={RED:FC.RED,GRN:FC.GRN,BLU:FC.BLU,YEL:FC.YEL};
|
|
2007
|
+
const BC_MAP={RED:BC.red,GRN:BC.grn,BLU:BC.blu,YEL:BC.yel};
|
|
2008
|
+
let sequence,playerIdx,showIdx,showing,showTimer,state,score,lit,msg,speed;
|
|
2009
|
+
|
|
2010
|
+
function start(){
|
|
2011
|
+
sequence=[COLORS[rng(4)]]; playerIdx=0; showIdx=0; showing=true;
|
|
2012
|
+
showTimer=0; state='playing'; score=0; lit=null; speed=800; msg='Watch...';
|
|
2013
|
+
em.emit('start');
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function input(key){
|
|
2017
|
+
if(state==='gameover'&&key==='enter'){start();return;}
|
|
2018
|
+
if(state!=='playing'||showing) return;
|
|
2019
|
+
const map={'1':'RED','2':'GRN','3':'BLU','4':'YEL'};
|
|
2020
|
+
const chosen=map[key]; if(!chosen) return;
|
|
2021
|
+
lit=chosen; setTimeout(()=>{lit=null;},200);
|
|
2022
|
+
if(chosen===sequence[playerIdx]){
|
|
2023
|
+
playerIdx++;
|
|
2024
|
+
if(playerIdx===sequence.length){
|
|
2025
|
+
score+=sequence.length; em.emit('score',score);
|
|
2026
|
+
sequence.push(COLORS[rng(4)]); speed=Math.max(300,speed-30);
|
|
2027
|
+
showIdx=0; playerIdx=0; showing=true; msg='Watch...';
|
|
2028
|
+
}
|
|
2029
|
+
} else {
|
|
2030
|
+
state='gameover'; msg=`Wrong! The sequence was: ${sequence.join(' ')}`; em.emit('gameover',score);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function tick(dt=16){
|
|
2035
|
+
if(state!=='playing'||!showing) return;
|
|
2036
|
+
showTimer+=dt;
|
|
2037
|
+
if(showTimer>=speed){
|
|
2038
|
+
showTimer=0; lit=null;
|
|
2039
|
+
if(showIdx<sequence.length){
|
|
2040
|
+
lit=sequence[showIdx]; showIdx++;
|
|
2041
|
+
} else {
|
|
2042
|
+
showing=false; lit=null; msg=`Round ${score/sequence.length+1}! Press 1-4`;
|
|
2043
|
+
}
|
|
2044
|
+
} else if(showTimer<speed*0.6){
|
|
2045
|
+
if(showIdx>0&&showIdx<=sequence.length) lit=sequence[showIdx-1];
|
|
2046
|
+
} else {
|
|
2047
|
+
lit=null;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function frame(){
|
|
2052
|
+
const W=40,H=18;
|
|
2053
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
2054
|
+
cv.put(0,0,` SIMON SAYS Round:${sequence.length} Score:${score}`,FC.YEL,BC.blk);
|
|
2055
|
+
// 4 color pads: 2×2 layout
|
|
2056
|
+
const pads=[['RED',1,2],['GRN',21,2],['BLU',1,10],['YEL',21,10]];
|
|
2057
|
+
const labels=[['RED','[1]'],['GRN','[2]'],['BLU','[3]'],['YEL','[4]']];
|
|
2058
|
+
for(let i=0;i<4;i++){
|
|
2059
|
+
const [col,ox,oy]=pads[i];
|
|
2060
|
+
const active=lit===col;
|
|
2061
|
+
const fc=active?FC.WHT:FC_MAP[col];
|
|
2062
|
+
const bc=active?BC_MAP[col]:BC.blk;
|
|
2063
|
+
cv.fill(ox,oy,16,6,' ',fc,bc);
|
|
2064
|
+
cv.put(ox+5,oy+2,` ${col} `,fc,bc);
|
|
2065
|
+
cv.put(ox+5,oy+3,` ${labels[i][1]} `,fc,bc);
|
|
2066
|
+
}
|
|
2067
|
+
cv.put(1,H-3,msg.padEnd(W-2,' '),FC.WHT,BC.blk);
|
|
2068
|
+
if(state==='gameover') cv.put(1,H-2,' GAME OVER ENTER restart ',FC.WHT,BC.red);
|
|
2069
|
+
return cv.render();
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
return { start, input, tick, frame,
|
|
2073
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
2074
|
+
get state(){return state;}, get score(){return score;} };
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// ─── GAME: ROCK PAPER SCISSORS LIZARD SPOCK ─────────────────────────────────
|
|
2078
|
+
|
|
2079
|
+
function makeRockPaperScissors(opts = {}) {
|
|
2080
|
+
const em=makeEmitter();
|
|
2081
|
+
const CHOICES=['Rock','Paper','Scissors','Lizard','Spock'];
|
|
2082
|
+
const BEATS={
|
|
2083
|
+
Rock:['Scissors','Lizard'], Paper:['Rock','Spock'],
|
|
2084
|
+
Scissors:['Paper','Lizard'], Lizard:['Spock','Paper'], Spock:['Rock','Scissors'],
|
|
2085
|
+
};
|
|
2086
|
+
const VERBS={
|
|
2087
|
+
'Rock-Scissors':'crushes','Rock-Lizard':'crushes',
|
|
2088
|
+
'Paper-Rock':'covers','Paper-Spock':'disproves',
|
|
2089
|
+
'Scissors-Paper':'cuts','Scissors-Lizard':'decapitates',
|
|
2090
|
+
'Lizard-Spock':'poisons','Lizard-Paper':'eats',
|
|
2091
|
+
'Spock-Rock':'vaporizes','Spock-Scissors':'smashes',
|
|
2092
|
+
};
|
|
2093
|
+
let curIdx,stats,state,score,msg,lastResult;
|
|
2094
|
+
|
|
2095
|
+
function start(){
|
|
2096
|
+
curIdx=0; stats={wins:0,losses:0,draws:0};
|
|
2097
|
+
state='playing'; score=0; msg='Choose your weapon!'; lastResult=null;
|
|
2098
|
+
em.emit('start');
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function play(choice){
|
|
2102
|
+
const ai=CHOICES[rng(5)];
|
|
2103
|
+
if(choice===ai){ stats.draws++; msg=`DRAW! Both chose ${choice}.`; }
|
|
2104
|
+
else if(BEATS[choice].includes(ai)){
|
|
2105
|
+
stats.wins++; score+=10; em.emit('score',score);
|
|
2106
|
+
const verb=VERBS[`${choice}-${ai}`]??'beats';
|
|
2107
|
+
msg=`WIN! ${choice} ${verb} ${ai}!`;
|
|
2108
|
+
} else {
|
|
2109
|
+
stats.losses++;
|
|
2110
|
+
const verb=VERBS[`${ai}-${choice}`]??'beats';
|
|
2111
|
+
msg=`LOSE! ${ai} ${verb} ${choice}.`;
|
|
2112
|
+
}
|
|
2113
|
+
lastResult={choice,ai};
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function input(key){
|
|
2117
|
+
if(state!=='playing') return;
|
|
2118
|
+
if(key==='left') curIdx=(curIdx-1+CHOICES.length)%CHOICES.length;
|
|
2119
|
+
if(key==='right') curIdx=(curIdx+1)%CHOICES.length;
|
|
2120
|
+
if(key==='enter'||key==='space') play(CHOICES[curIdx]);
|
|
2121
|
+
// shortcuts
|
|
2122
|
+
const shortcuts={r:0,p:1,s:2,l:3,v:4};
|
|
2123
|
+
if(shortcuts[key]!==undefined){ curIdx=shortcuts[key]; play(CHOICES[curIdx]); }
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function frame(){
|
|
2127
|
+
const W=50,H=16;
|
|
2128
|
+
const cv=makeCanvas(W,H); cv.clear(' ',FC.wht,BC.blk);
|
|
2129
|
+
cv.put(0,0,` RPS-LS Score:${score} W:${stats.wins} L:${stats.losses} D:${stats.draws}`,FC.YEL,BC.blk);
|
|
2130
|
+
cv.put(1,2,'Rock Paper Scissors Lizard Spock',FC.BLK,BC.blk);
|
|
2131
|
+
cv.put(1,3,'[R] [P] [S] [L] [V]',FC.BLK,BC.blk);
|
|
2132
|
+
// selector
|
|
2133
|
+
const offsets=[0,6,13,23,30];
|
|
2134
|
+
cv.put(offsets[curIdx]+1,2,CHOICES[curIdx],FC.CYN,BC.blk);
|
|
2135
|
+
cv.put(offsets[curIdx]+1,4,'▲',FC.YEL,BC.blk);
|
|
2136
|
+
// icons
|
|
2137
|
+
const ICONS=['🪨','📄','✂️','🦎','🖖'];
|
|
2138
|
+
const txts=['Rock ','Paper','Sciss','Lizrd','Spock'];
|
|
2139
|
+
cv.put(1,5,'[←→ select ENTER choose or R/P/S/L/V]',FC.BLK,BC.blk);
|
|
2140
|
+
if(msg) cv.put(1,7,msg.padEnd(W-2,' '),FC.WHT,BC.blk);
|
|
2141
|
+
if(lastResult){
|
|
2142
|
+
cv.put(1,9,`You: ${lastResult.choice.padEnd(10,' ')} AI: ${lastResult.ai}`,FC.CYN,BC.blk);
|
|
2143
|
+
}
|
|
2144
|
+
cv.put(1,11,'Win conditions:',FC.BLK,BC.blk);
|
|
2145
|
+
cv.put(1,12,'Rock crushes Scissors/Lizard',FC.BLK,BC.blk);
|
|
2146
|
+
cv.put(1,13,'Paper covers Rock, disproves Spock',FC.BLK,BC.blk);
|
|
2147
|
+
cv.put(1,14,'Scissors cuts Paper, decapitates Lizard',FC.BLK,BC.blk);
|
|
2148
|
+
cv.put(1,15,'Lizard poisons Spock, eats Paper',FC.BLK,BC.blk);
|
|
2149
|
+
return cv.render();
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
return { start, input, tick(){}, frame,
|
|
2153
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
2154
|
+
get state(){return state;}, get score(){return score;} };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// ─── GAME: DUNGEON CRAWLER ───────────────────────────────────────────────────
|
|
2158
|
+
|
|
2159
|
+
function makeDungeon(opts = {}) {
|
|
2160
|
+
const em=makeEmitter();
|
|
2161
|
+
const W=5, H=4; // room grid
|
|
2162
|
+
const MONSTERS=[
|
|
2163
|
+
{name:'Slime',hp:8,atk:2,xp:15,gold:5},
|
|
2164
|
+
{name:'Goblin',hp:14,atk:4,xp:25,gold:10},
|
|
2165
|
+
{name:'Orc',hp:22,atk:6,xp:40,gold:20},
|
|
2166
|
+
{name:'Troll',hp:35,atk:8,xp:60,gold:35},
|
|
2167
|
+
{name:'Dragon',hp:60,atk:12,xp:120,gold:80},
|
|
2168
|
+
];
|
|
2169
|
+
const ITEMS=[
|
|
2170
|
+
{name:'Health Potion',effect:'heal',val:20,cost:15},
|
|
2171
|
+
{name:'Sword',effect:'atk',val:3,cost:30},
|
|
2172
|
+
{name:'Shield',effect:'def',val:2,cost:25},
|
|
2173
|
+
{name:'Elixir',effect:'heal',val:50,cost:40},
|
|
2174
|
+
];
|
|
2175
|
+
let rooms,px,py,player,combat,state,score,msg,log;
|
|
2176
|
+
|
|
2177
|
+
function makeRooms(){
|
|
2178
|
+
const types=['empty','empty','empty','monster','monster','monster','monster','treasure','shop','boss'];
|
|
2179
|
+
const shuffled=shuffle(types);
|
|
2180
|
+
const grid=Array.from({length:H},(_,r)=>Array.from({length:W},(_,c)=>{
|
|
2181
|
+
const idx=r*W+c;
|
|
2182
|
+
if(r===0&&c===0) return {type:'empty',visited:false,cleared:true,monster:null,item:null};
|
|
2183
|
+
const t=shuffled[idx%shuffled.length];
|
|
2184
|
+
let monster=null, item=null;
|
|
2185
|
+
if(t==='monster'||t==='boss'){
|
|
2186
|
+
const mi=t==='boss'?4:rng(4);
|
|
2187
|
+
monster={...MONSTERS[mi],maxHp:MONSTERS[mi].hp,hp:MONSTERS[mi].hp};
|
|
2188
|
+
}
|
|
2189
|
+
if(t==='treasure') item=ITEMS[rng(ITEMS.length)];
|
|
2190
|
+
return {type:t,visited:false,cleared:t==='empty',monster,item};
|
|
2191
|
+
}));
|
|
2192
|
+
// place exit at bottom-right
|
|
2193
|
+
grid[H-1][W-1]={type:'exit',visited:false,cleared:true};
|
|
2194
|
+
return grid;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function start(){
|
|
2198
|
+
rooms=makeRooms(); px=0; py=0; rooms[0][0].visited=true;
|
|
2199
|
+
player={hp:30,maxHp:30,atk:5,def:2,level:1,xp:0,xpNext:100,gold:20,inv:[]};
|
|
2200
|
+
combat=null; state='playing'; score=0; log=[]; msg='Find the exit ★!';
|
|
2201
|
+
em.emit('start');
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function addLog(s){ log.unshift(s); if(log.length>5) log.pop(); }
|
|
2205
|
+
|
|
2206
|
+
function enterRoom(){
|
|
2207
|
+
const room=rooms[py][px];
|
|
2208
|
+
room.visited=true;
|
|
2209
|
+
if(room.type==='exit'){ score=player.gold+player.xp; state='won'; em.emit('win',score); return; }
|
|
2210
|
+
if(room.type==='treasure'&&room.item&&!room.cleared){
|
|
2211
|
+
const it=room.item;
|
|
2212
|
+
player.inv.push(it.name);
|
|
2213
|
+
if(it.effect==='heal') player.hp=Math.min(player.maxHp,player.hp+it.val);
|
|
2214
|
+
if(it.effect==='atk') player.atk+=it.val;
|
|
2215
|
+
if(it.effect==='def') player.def+=it.val;
|
|
2216
|
+
addLog(`Found ${it.name}!`); room.cleared=true; return;
|
|
2217
|
+
}
|
|
2218
|
+
if((room.type==='monster'||room.type==='boss')&&room.monster&&!room.cleared){
|
|
2219
|
+
combat={...room.monster}; msg=`Fighting ${combat.name}! A=attack F=flee`;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function attackRound(flee=false){
|
|
2224
|
+
if(!combat) return;
|
|
2225
|
+
if(flee){
|
|
2226
|
+
if(Math.random()<0.4){
|
|
2227
|
+
combat=null; px=clamp(px+(rng(2)?1:-1),0,W-1); py=clamp(py+(rng(2)?1:-1),0,H-1);
|
|
2228
|
+
addLog('Fled!'); return;
|
|
2229
|
+
} else { addLog('Couldn\'t flee!'); }
|
|
2230
|
+
}
|
|
2231
|
+
// player attacks
|
|
2232
|
+
const pdmg=Math.max(1,(Math.random()*player.atk|0)+1);
|
|
2233
|
+
combat.hp-=pdmg; addLog(`Hit ${combat.name} for ${pdmg}`);
|
|
2234
|
+
if(combat.hp<=0){
|
|
2235
|
+
const room=rooms[py][px];
|
|
2236
|
+
room.cleared=true; room.monster=null;
|
|
2237
|
+
player.xp+=combat.xp; player.gold+=combat.gold;
|
|
2238
|
+
addLog(`${combat.name} defeated! +${combat.xp}xp +${combat.gold}g`);
|
|
2239
|
+
while(player.xp>=player.xpNext){
|
|
2240
|
+
player.xp-=player.xpNext; player.level++; player.xpNext=player.level*100;
|
|
2241
|
+
player.atk+=2; player.def+=1; player.maxHp+=10; player.hp=player.maxHp;
|
|
2242
|
+
addLog(`Level up! Now level ${player.level}`);
|
|
2243
|
+
}
|
|
2244
|
+
combat=null; msg='Room cleared!'; return;
|
|
2245
|
+
}
|
|
2246
|
+
// monster attacks
|
|
2247
|
+
const mdmg=Math.max(1,(Math.random()*combat.atk|0)+1-player.def);
|
|
2248
|
+
player.hp-=mdmg; addLog(`${combat.name} hits you for ${mdmg}`);
|
|
2249
|
+
if(player.hp<=0){ player.hp=0; state='gameover'; em.emit('gameover',score); msg='You died!'; combat=null; }
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function usePotion(){
|
|
2253
|
+
const idx=player.inv.indexOf('Health Potion');
|
|
2254
|
+
if(idx<0){ addLog('No potions!'); return; }
|
|
2255
|
+
player.inv.splice(idx,1);
|
|
2256
|
+
player.hp=Math.min(player.maxHp,player.hp+20);
|
|
2257
|
+
addLog('Used Health Potion +20 HP');
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function input(key){
|
|
2261
|
+
if((state==='gameover'||state==='won')&&key==='enter'){start();return;}
|
|
2262
|
+
if(state!=='playing') return;
|
|
2263
|
+
if(combat){
|
|
2264
|
+
if(key==='a'||key==='space') attackRound(false);
|
|
2265
|
+
if(key==='f') attackRound(true);
|
|
2266
|
+
if(key==='p') usePotion();
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
let moved=false;
|
|
2270
|
+
if(key==='up' &&py>0) { py--; moved=true; }
|
|
2271
|
+
if(key==='down' &&py<H-1) { py++; moved=true; }
|
|
2272
|
+
if(key==='left' &&px>0) { px--; moved=true; }
|
|
2273
|
+
if(key==='right'&&px<W-1) { px++; moved=true; }
|
|
2274
|
+
if(moved) enterRoom();
|
|
2275
|
+
if(key==='p') usePotion();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function frame(){
|
|
2279
|
+
const CW=W*6+4,CH=H*4+2, totalW=CW+30, totalH=Math.max(CH,18)+3;
|
|
2280
|
+
const cv=makeCanvas(totalW,totalH); cv.clear(' ',FC.wht,BC.blk);
|
|
2281
|
+
cv.put(0,0,` DUNGEON Level:${player.level} Gold:${player.gold} XP:${player.xp}/${player.xpNext}`,FC.YEL,BC.blk);
|
|
2282
|
+
// map
|
|
2283
|
+
const icons={empty:' ',monster:'M',boss:'B',treasure:'T',shop:'$',exit:'★'};
|
|
2284
|
+
const icols={empty:FC.BLK,monster:FC.RED,boss:FC.RED,treasure:FC.YEL,shop:FC.GRN,exit:FC.GRN};
|
|
2285
|
+
for(let r=0;r<H;r++) for(let c=0;c<W;c++){
|
|
2286
|
+
const room=rooms[r][c];
|
|
2287
|
+
const isHere=c===px&&r===py;
|
|
2288
|
+
const bx=c*6+1, by=r*4+2;
|
|
2289
|
+
cv.box(bx,by,5,3,isHere?FC.YEL:FC.BLK,BC.blk);
|
|
2290
|
+
if(room.visited){
|
|
2291
|
+
const ic=room.cleared?icons[room.type]??'?':icons[room.type]??'?';
|
|
2292
|
+
const col=room.cleared&&room.type!=='exit'?FC.BLK:icols[room.type]??FC.wht;
|
|
2293
|
+
cv.set(bx+2,by+1,isHere?'@':ic,isHere?FC.YEL:col,BC.blk);
|
|
2294
|
+
} else {
|
|
2295
|
+
cv.set(bx+2,by+1,'?',FC.BLK,BC.blk);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
// stats
|
|
2299
|
+
const sx=CW+2;
|
|
2300
|
+
cv.put(sx,2, `HP: ${player.hp}/${player.maxHp}`,player.hp<10?FC.RED:FC.GRN,BC.blk);
|
|
2301
|
+
cv.put(sx,3, `ATK:${player.atk} DEF:${player.def}`,FC.CYN,BC.blk);
|
|
2302
|
+
cv.put(sx,4, `Inv: ${player.inv.slice(0,3).join(', ')||'empty'}`,FC.wht,BC.blk);
|
|
2303
|
+
cv.put(sx,6, 'LOG:',FC.BLK,BC.blk);
|
|
2304
|
+
for(let i=0;i<Math.min(log.length,5);i++) cv.put(sx,7+i,log[i].slice(0,24),FC.wht,BC.blk);
|
|
2305
|
+
// combat
|
|
2306
|
+
if(combat){
|
|
2307
|
+
cv.put(sx,13,`COMBAT: ${combat.name}`,FC.RED,BC.blk);
|
|
2308
|
+
cv.put(sx,14,`Enemy HP: ${combat.hp}/${combat.maxHp}`,FC.RED,BC.blk);
|
|
2309
|
+
cv.put(sx,15,'A=attack F=flee P=potion',FC.YEL,BC.blk);
|
|
2310
|
+
} else {
|
|
2311
|
+
cv.put(sx,13,'[Arrows move P=potion]',FC.BLK,BC.blk);
|
|
2312
|
+
cv.put(sx,14,msg.slice(0,24),FC.wht,BC.blk);
|
|
2313
|
+
}
|
|
2314
|
+
cv.put(0,totalH-1,' M=Monster T=Treasure $=Shop ★=Exit ',FC.BLK,BC.blk);
|
|
2315
|
+
if(state==='gameover') cv.put(sx,16,' DEAD ENTER restart ',FC.WHT,BC.red);
|
|
2316
|
+
if(state==='won') cv.put(sx,16,` ESCAPED! Score:${score} ENTER again `,FC.WHT,BC.grn);
|
|
2317
|
+
return cv.render();
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
return { start, input, tick(){}, frame,
|
|
2321
|
+
on:em.on.bind(em), off:em.off.bind(em),
|
|
2322
|
+
get state(){return state;}, get score(){return score;} };
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// ─── GAME: LEARNING CHATBOT ──────────────────────────────────────────────────
|
|
2326
|
+
|
|
2327
|
+
function makeLearningChatbot(opts = {}) {
|
|
2328
|
+
const em=makeEmitter();
|
|
2329
|
+
|
|
2330
|
+
const SLANG_SET=new Set(['lol','lmao','lmfao','omg','ngl','tbh','imo','idk','bruh','fr','nah','yea','ye','sus','vibes','slay','lowkey','highkey','based','cringe','bussin','no cap','deadass','bet','fire','snatched','rent free','understood the assignment','it hits different',
|
|
2331
|
+
'main character','no printer','say less','big yikes','ick','rizz','simp','NPC',
|
|
2332
|
+
'W','L','ratio','mid','slaps','hits different','touch grass','ate that']);
|
|
2333
|
+
|
|
2334
|
+
const FORMAL_MAP={
|
|
2335
|
+
'lol':'haha','lmao':'haha','lmfao':'haha','omg':'oh my','ngl':'honestly',
|
|
2336
|
+
'tbh':'to be honest','imo':'in my opinion','idk':'I don\'t know','bruh':'come on',
|
|
2337
|
+
'fr':'for real','nah':'no','yea':'yes','ye':'yes','sus':'suspicious',
|
|
2338
|
+
'bet':'okay','W':'win','L':'loss','mid':'mediocre','rizz':'charm',
|
|
2339
|
+
'simp':'someone who is overly devoted','NPC':'background character',
|
|
2340
|
+
'ratio':'when a reply gets more likes than the original post',
|
|
2341
|
+
'no cap':'no lie','deadass':'seriously','bussin':'delicious',
|
|
2342
|
+
'slay':'do something excellently','lowkey':'somewhat secretly',
|
|
2343
|
+
'highkey':'very openly','based':'confident and unapologetically yourself',
|
|
2344
|
+
'cringe':'embarrassing','vibes':'atmosphere or feeling',
|
|
2345
|
+
'touch grass':'go outside and engage with real life',
|
|
2346
|
+
'understood the assignment':'performed perfectly',
|
|
2347
|
+
'it hits different':'feels uniquely special',
|
|
2348
|
+
'main character':'acts like they\'re the protagonist of life',
|
|
2349
|
+
'rent free':'occupying your thoughts without effort',
|
|
2350
|
+
'fire':'amazing','snatched':'looking great','ick':'sudden repulsion',
|
|
2351
|
+
'say less':'understood, no more needs to be said',
|
|
2352
|
+
'big yikes':'that\'s very unfortunate','slaps':'is really good',
|
|
2353
|
+
'ate that':'did something perfectly',
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2356
|
+
// conversation state
|
|
2357
|
+
let msgs, learnQueue, score, state, inputBuf, mode;
|
|
2358
|
+
// mode: 'chat' | 'quiz'
|
|
2359
|
+
let quizWord, quizAnswer, quizStreak;
|
|
2360
|
+
|
|
2361
|
+
function start() {
|
|
2362
|
+
msgs = [
|
|
2363
|
+
{ from:'bot', text:'Hey! I\'m SlangBot. I learn new slang from you and quiz you on it.' },
|
|
2364
|
+
{ from:'bot', text:'Chat normally — I\'ll flag words I don\'t recognize. Type /quiz to test yourself, /list to see what I know.' },
|
|
2365
|
+
];
|
|
2366
|
+
learnQueue = []; score = 0; state = 'playing'; inputBuf = ''; mode = 'chat';
|
|
2367
|
+
quizWord = null; quizAnswer = null; quizStreak = 0;
|
|
2368
|
+
em.emit('start');
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function botReply(text) { msgs.push({ from:'bot', text }); if (msgs.length > 30) msgs.shift(); }
|
|
2372
|
+
function userMsg(text) { msgs.push({ from:'usr', text }); if (msgs.length > 30) msgs.shift(); }
|
|
2373
|
+
|
|
2374
|
+
function tokenize(s) { return s.toLowerCase().match(/[a-z']+/g) ?? []; }
|
|
2375
|
+
|
|
2376
|
+
function processChat(line) {
|
|
2377
|
+
const words = tokenize(line);
|
|
2378
|
+
const unknown = words.filter(w => !SLANG_SET.has(w) && !FORMAL_MAP[w] && w.length > 2 &&
|
|
2379
|
+
!['the','and','but','for','you','are','was','not','that','this','with','have',
|
|
2380
|
+
'from','they','will','been','just','when','what','your','can','its','him',
|
|
2381
|
+
'her','had','his','has','she','did','how','all','one','out','who','get',
|
|
2382
|
+
'use','say','way','may','see','now','our','any','new','two','day','got',
|
|
2383
|
+
'let','put','man','too','old','why','did','few','off','set','own','run',
|
|
2384
|
+
'ive','im','its','id','dont','cant','wont','isnt','arent','wasnt',
|
|
2385
|
+
'shouldnt','wouldnt','couldnt','aint'].includes(w));
|
|
2386
|
+
|
|
2387
|
+
// translate known slang
|
|
2388
|
+
const translated = words.map(w => FORMAL_MAP[w] ? `${FORMAL_MAP[w]}` : w).join(' ');
|
|
2389
|
+
const hasSlang = words.some(w => SLANG_SET.has(w) || FORMAL_MAP[w]);
|
|
2390
|
+
|
|
2391
|
+
if (hasSlang) {
|
|
2392
|
+
botReply(`Decoded: "${translated}"`);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
if (unknown.length > 0) {
|
|
2396
|
+
learnQueue.push(...unknown);
|
|
2397
|
+
botReply(`Hmm, I don't know: ${unknown.map(w=>`"${w}"`).join(', ')}. What do they mean? (type: word = meaning)`);
|
|
2398
|
+
} else if (!hasSlang) {
|
|
2399
|
+
// generic responses
|
|
2400
|
+
const responses = [
|
|
2401
|
+
'Interesting! Tell me more.',
|
|
2402
|
+
'Got it. Drop some slang and I\'ll decode it.',
|
|
2403
|
+
'Cool. Try /quiz to test your slang knowledge.',
|
|
2404
|
+
'Noted! I\'m always learning.',
|
|
2405
|
+
];
|
|
2406
|
+
botReply(responses[msgs.length % responses.length]);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function processDefinition(line) {
|
|
2411
|
+
// format: word = meaning OR word: meaning
|
|
2412
|
+
const m = line.match(/^([a-z'\s]+?)\s*[=:]\s*(.+)$/i);
|
|
2413
|
+
if (!m) { botReply('Try: word = meaning'); return; }
|
|
2414
|
+
const word = m[1].trim().toLowerCase();
|
|
2415
|
+
const meaning = m[2].trim();
|
|
2416
|
+
if (FORMAL_MAP[word]) {
|
|
2417
|
+
botReply(`I already know "${word}" means "${FORMAL_MAP[word]}"!`);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
FORMAL_MAP[word] = meaning;
|
|
2421
|
+
SLANG_SET.add(word);
|
|
2422
|
+
learnQueue = learnQueue.filter(w => w !== word);
|
|
2423
|
+
score += 5; em.emit('score', score);
|
|
2424
|
+
botReply(`Learned! "${word}" = "${meaning}" ✓ (+5 points)`);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function startQuiz() {
|
|
2428
|
+
const known = Object.keys(FORMAL_MAP);
|
|
2429
|
+
if (known.length < 3) { botReply('I need to know at least 3 words first. Chat with me!'); return; }
|
|
2430
|
+
quizWord = known[rng(known.length)];
|
|
2431
|
+
quizAnswer = FORMAL_MAP[quizWord];
|
|
2432
|
+
mode = 'quiz';
|
|
2433
|
+
botReply(`QUIZ TIME! What does "${quizWord}" mean? (type your answer)`);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function checkQuiz(line) {
|
|
2437
|
+
const ans = line.toLowerCase().trim();
|
|
2438
|
+
const correct = quizAnswer.toLowerCase();
|
|
2439
|
+
// fuzzy: share 2+ words or substring
|
|
2440
|
+
const aWords = new Set(ans.split(/\s+/));
|
|
2441
|
+
const cWords = correct.split(/\s+/);
|
|
2442
|
+
const overlap = cWords.filter(w => aWords.has(w) || ans.includes(w)).length;
|
|
2443
|
+
const pass = overlap >= 1 || ans.includes(correct.slice(0,5));
|
|
2444
|
+
if (pass) {
|
|
2445
|
+
quizStreak++; score += 10 * quizStreak; em.emit('score', score);
|
|
2446
|
+
botReply(`✓ Correct! "${quizWord}" = "${quizAnswer}". Streak: ${quizStreak}x! (+${10*quizStreak}pts)`);
|
|
2447
|
+
} else {
|
|
2448
|
+
quizStreak = 0;
|
|
2449
|
+
botReply(`✗ Nope! "${quizWord}" means "${quizAnswer}". Streak reset.`);
|
|
2450
|
+
}
|
|
2451
|
+
mode = 'chat'; quizWord = null;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function input(key) {
|
|
2455
|
+
if (state !== 'playing') { if (key === 'enter') start(); return; }
|
|
2456
|
+
if (key === 'enter') {
|
|
2457
|
+
const line = inputBuf.trim(); inputBuf = '';
|
|
2458
|
+
if (!line) return;
|
|
2459
|
+
userMsg(line);
|
|
2460
|
+
const lower = line.toLowerCase();
|
|
2461
|
+
if (mode === 'quiz') { checkQuiz(line); return; }
|
|
2462
|
+
if (lower === '/quiz') { startQuiz(); return; }
|
|
2463
|
+
if (lower === '/list') {
|
|
2464
|
+
const known = Object.keys(FORMAL_MAP);
|
|
2465
|
+
botReply(`I know ${known.length} words: ${known.slice(-8).join(', ')}${known.length>8?' …':''}`)
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (lower === '/reset') { botReply('Starting fresh!'); start(); return; }
|
|
2469
|
+
if (line.match(/^[a-z'\s]+\s*[=:]\s*.+$/i) && learnQueue.length > 0) {
|
|
2470
|
+
processDefinition(line);
|
|
2471
|
+
} else {
|
|
2472
|
+
processChat(line);
|
|
2473
|
+
}
|
|
2474
|
+
} else if (key === 'backspace' || key === 'delete') {
|
|
2475
|
+
inputBuf = inputBuf.slice(0, -1);
|
|
2476
|
+
} else if (key.length === 1) {
|
|
2477
|
+
if (inputBuf.length < 58) inputBuf += key;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
function frame() {
|
|
2482
|
+
const W = 62, H = 22;
|
|
2483
|
+
const cv = makeCanvas(W, H); cv.clear(' ', FC.wht, BC.blk);
|
|
2484
|
+
cv.put(0, 0, ` SLANGBOT Score:${score} Streak:${quizStreak}x /quiz /list /reset`, FC.YEL, BC.blk);
|
|
2485
|
+
cv.put(0, 1, '─'.repeat(W), FC.BLK, BC.blk);
|
|
2486
|
+
// messages
|
|
2487
|
+
const visible = msgs.slice(-16);
|
|
2488
|
+
for (let i = 0; i < visible.length; i++) {
|
|
2489
|
+
const m = visible[i];
|
|
2490
|
+
const isBot = m.from === 'bot';
|
|
2491
|
+
const prefix = isBot ? 'BOT: ' : 'YOU: ';
|
|
2492
|
+
const col = isBot ? FC.CYN : FC.GRN;
|
|
2493
|
+
const text = (prefix + m.text).slice(0, W - 1);
|
|
2494
|
+
cv.put(0, i + 2, text, col, BC.blk);
|
|
2495
|
+
}
|
|
2496
|
+
// input line
|
|
2497
|
+
cv.put(0, H - 2, '─'.repeat(W), FC.BLK, BC.blk);
|
|
2498
|
+
const prompt = `> ${inputBuf}${Math.floor(Date.now()/500)%2===0?'▌':' '}`;
|
|
2499
|
+
cv.put(0, H - 1, prompt.padEnd(W, ' '), FC.WHT, BC.blk);
|
|
2500
|
+
if (mode === 'quiz') cv.put(W - 12, 0, ' [QUIZ] ', FC.WHT, BC.red);
|
|
2501
|
+
return cv.render();
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
return {
|
|
2505
|
+
start, input, tick() {}, frame,
|
|
2506
|
+
on: em.on.bind(em), off: em.off.bind(em),
|
|
2507
|
+
get state() { return state; }, get score() { return score; },
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
'use strict';
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* RunGame(game)
|
|
2515
|
+
*
|
|
2516
|
+
* Drives any kitarcade-compatible game object.
|
|
2517
|
+
* Handles: raw stdin key parsing, tick loop, frame rendering, clean exit.
|
|
2518
|
+
*
|
|
2519
|
+
* Usage:
|
|
2520
|
+
* RunGame(kitdef.Snake());
|
|
2521
|
+
*
|
|
2522
|
+
* // or pass a factory and let RunGame call it:
|
|
2523
|
+
* RunGame(Tetris);
|
|
2524
|
+
*/
|
|
2525
|
+
|
|
2526
|
+
function RunGame(game) {
|
|
2527
|
+
// accept either a live game object or a factory function
|
|
2528
|
+
if (typeof game === 'function') game = game();
|
|
2529
|
+
|
|
2530
|
+
const FPS = 30;
|
|
2531
|
+
const INTERVAL = 1000 / FPS;
|
|
2532
|
+
|
|
2533
|
+
// ── terminal setup ────────────────────────────────────────────────────────
|
|
2534
|
+
|
|
2535
|
+
const { stdin, stdout } = process;
|
|
2536
|
+
|
|
2537
|
+
function hideCursor() { stdout.write('\x1b[?25l'); }
|
|
2538
|
+
function showCursor() { stdout.write('\x1b[?25h'); }
|
|
2539
|
+
function clearScreen() { stdout.write('\x1b[2J\x1b[H'); }
|
|
2540
|
+
function home() { stdout.write('\x1b[H'); }
|
|
2541
|
+
function altScreen() { stdout.write('\x1b[?1049h'); }
|
|
2542
|
+
function mainScreen() { stdout.write('\x1b[?1049l'); }
|
|
2543
|
+
|
|
2544
|
+
// ── cleanup / exit ────────────────────────────────────────────────────────
|
|
2545
|
+
|
|
2546
|
+
let exiting = false;
|
|
2547
|
+
|
|
2548
|
+
function exit(code = 0) {
|
|
2549
|
+
if (exiting) return;
|
|
2550
|
+
exiting = true;
|
|
2551
|
+
clearInterval(tickTimer);
|
|
2552
|
+
if (stdin.isTTY) {
|
|
2553
|
+
stdin.setRawMode(false);
|
|
2554
|
+
stdin.pause();
|
|
2555
|
+
}
|
|
2556
|
+
showCursor();
|
|
2557
|
+
mainScreen();
|
|
2558
|
+
process.exit(code);
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// ── key parsing ───────────────────────────────────────────────────────────
|
|
2562
|
+
|
|
2563
|
+
// Maps raw terminal bytes → game key names
|
|
2564
|
+
const KEY_MAP = {
|
|
2565
|
+
'\x1b[A': 'up',
|
|
2566
|
+
'\x1b[B': 'down',
|
|
2567
|
+
'\x1b[C': 'right',
|
|
2568
|
+
'\x1b[D': 'left',
|
|
2569
|
+
'\x1b[1;2A': 'up', // shift-up (some terms)
|
|
2570
|
+
'\x1b[1;2B': 'down',
|
|
2571
|
+
'\x1b[1;2C': 'right',
|
|
2572
|
+
'\x1b[1;2D': 'left',
|
|
2573
|
+
'\x1bOA': 'up', // application cursor keys
|
|
2574
|
+
'\x1bOB': 'down',
|
|
2575
|
+
'\x1bOC': 'right',
|
|
2576
|
+
'\x1bOD': 'left',
|
|
2577
|
+
'\r': 'enter',
|
|
2578
|
+
'\n': 'enter',
|
|
2579
|
+
' ': 'space',
|
|
2580
|
+
'\t': 'tab',
|
|
2581
|
+
'\x1b': 'escape',
|
|
2582
|
+
'\x7f': 'backspace',
|
|
2583
|
+
'\x08': 'backspace',
|
|
2584
|
+
'\x1b[3~':'delete',
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
function parseKey(buf) {
|
|
2588
|
+
const s = buf.toString();
|
|
2589
|
+
if (KEY_MAP[s]) return KEY_MAP[s];
|
|
2590
|
+
// single printable ASCII
|
|
2591
|
+
if (s.length === 1 && s >= ' ' && s <= '~') return s.toLowerCase();
|
|
2592
|
+
// ctrl-c / ctrl-d → exit
|
|
2593
|
+
if (s === '\x03' || s === '\x04') { exit(0); return null; }
|
|
2594
|
+
return null;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// ── render ────────────────────────────────────────────────────────────────
|
|
2598
|
+
|
|
2599
|
+
let lastFrame = '';
|
|
2600
|
+
|
|
2601
|
+
function render() {
|
|
2602
|
+
const f = game.frame();
|
|
2603
|
+
// only redraw if output changed (avoids flicker on idle games)
|
|
2604
|
+
if (f === lastFrame) return;
|
|
2605
|
+
lastFrame = f;
|
|
2606
|
+
home();
|
|
2607
|
+
stdout.write(f);
|
|
2608
|
+
// clear any leftover lines below the frame
|
|
2609
|
+
stdout.write('\x1b[J');
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// ── tick loop ─────────────────────────────────────────────────────────────
|
|
2613
|
+
|
|
2614
|
+
let prev = Date.now();
|
|
2615
|
+
const tickTimer = setInterval(() => {
|
|
2616
|
+
const now = Date.now();
|
|
2617
|
+
const dt = now - prev;
|
|
2618
|
+
prev = now;
|
|
2619
|
+
game.tick(dt);
|
|
2620
|
+
render();
|
|
2621
|
+
}, INTERVAL);
|
|
2622
|
+
|
|
2623
|
+
// ── stdin ─────────────────────────────────────────────────────────────────
|
|
2624
|
+
|
|
2625
|
+
if (stdin.isTTY) {
|
|
2626
|
+
stdin.setRawMode(true);
|
|
2627
|
+
} else {
|
|
2628
|
+
// piped / non-TTY: still work, just no raw keys
|
|
2629
|
+
process.stderr.write('[RunGame] stdin is not a TTY — keyboard input unavailable\n');
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
stdin.resume();
|
|
2633
|
+
stdin.on('data', (buf) => {
|
|
2634
|
+
const key = parseKey(buf);
|
|
2635
|
+
if (key === null) return;
|
|
2636
|
+
game.input(key);
|
|
2637
|
+
// immediate render on input so the game feels responsive
|
|
2638
|
+
render();
|
|
2639
|
+
});
|
|
2640
|
+
|
|
2641
|
+
// ── game events ───────────────────────────────────────────────────────────
|
|
2642
|
+
|
|
2643
|
+
game.on('gameover', (score) => {
|
|
2644
|
+
render(); // show gameover frame
|
|
2645
|
+
// don't auto-exit — player can press ENTER to restart per game convention
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
game.on('win', (score) => {
|
|
2649
|
+
render();
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
// ── go ────────────────────────────────────────────────────────────────────
|
|
2653
|
+
|
|
2654
|
+
altScreen();
|
|
2655
|
+
hideCursor();
|
|
2656
|
+
clearScreen();
|
|
2657
|
+
render();
|
|
2658
|
+
|
|
2659
|
+
// handle process signals
|
|
2660
|
+
process.on('SIGINT', () => exit(0));
|
|
2661
|
+
process.on('SIGTERM', () => exit(0));
|
|
2662
|
+
process.on('exit', () => { showCursor(); mainScreen(); });
|
|
2663
|
+
|
|
2664
|
+
// return a handle in case the caller wants to stop it programmatically
|
|
2665
|
+
return {
|
|
2666
|
+
stop: () => exit(0),
|
|
2667
|
+
game,
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// ─── EXPORT ──────────────────────────────────────────────────────────────────
|
|
2672
|
+
|
|
2673
|
+
const kitdef = {
|
|
2674
|
+
Snake: (opts) => { const g = makeSnake(opts); g.start(); return g; },
|
|
2675
|
+
Tetris: (opts) => { const g = makeTetris(opts); g.start(); return g; },
|
|
2676
|
+
DinoRun: (opts) => { const g = makeDinoRun(opts); g.start(); return g; },
|
|
2677
|
+
Breakout: (opts) => { const g = makeBreakout(opts); g.start(); return g; },
|
|
2678
|
+
SpaceInvaders: (opts) => { const g = makeSpaceInvaders(opts); g.start(); return g; },
|
|
2679
|
+
FlappyBird: (opts) => { const g = makeFlappyBird(opts); g.start(); return g; },
|
|
2680
|
+
Asteroids: (opts) => { const g = makeAsteroids(opts); g.start(); return g; },
|
|
2681
|
+
TwentyFortyEight: (opts) => { const g = makeTwentyFortyEight(opts); g.start(); return g; },
|
|
2682
|
+
Minesweeper: (opts) => { const g = makeMinesweeper(opts); g.start(); return g; },
|
|
2683
|
+
Wordle: (opts) => { const g = makeWordle(opts); g.start(); return g; },
|
|
2684
|
+
ConnectFour: (opts) => { const g = makeConnectFour(opts); g.start(); return g; },
|
|
2685
|
+
Battleship: (opts) => { const g = makeBattleship(opts); g.start(); return g; },
|
|
2686
|
+
Maze: (opts) => { const g = makeMaze(opts); g.start(); return g; },
|
|
2687
|
+
LightsOut: (opts) => { const g = makeLightsOut(opts); g.start(); return g; },
|
|
2688
|
+
Sudoku: (opts) => { const g = makeSudoku(opts); g.start(); return g; },
|
|
2689
|
+
Blackjack: (opts) => { const g = makeBlackjack(opts); g.start(); return g; },
|
|
2690
|
+
Slots: (opts) => { const g = makeSlots(opts); g.start(); return g; },
|
|
2691
|
+
VideoPoker: (opts) => { const g = makeVideoPoker(opts); g.start(); return g; },
|
|
2692
|
+
Hangman: (opts) => { const g = makeHangman(opts); g.start(); return g; },
|
|
2693
|
+
TypingTest: (opts) => { const g = makeTypingTest(opts); g.start(); return g; },
|
|
2694
|
+
Simon: (opts) => { const g = makeSimon(opts); g.start(); return g; },
|
|
2695
|
+
RockPaperScissors:(opts) => { const g = makeRockPaperScissors(opts); g.start(); return g; },
|
|
2696
|
+
Dungeon: (opts) => { const g = makeDungeon(opts); g.start(); return g; },
|
|
2697
|
+
SlangBot: (opts) => { const g = makeLearningChatbot(opts); g.start(); return g; },
|
|
2698
|
+
RunGame, // export the runner too, in case someone wants to use it directly
|
|
2699
|
+
|
|
2700
|
+
// list all available game names
|
|
2701
|
+
games: [
|
|
2702
|
+
'Snake','Tetris','DinoRun','Breakout','SpaceInvaders','FlappyBird',
|
|
2703
|
+
'Asteroids','TwentyFortyEight','Minesweeper','Wordle','ConnectFour',
|
|
2704
|
+
'Battleship','Maze','LightsOut','Sudoku','Blackjack','Slots','VideoPoker',
|
|
2705
|
+
'Hangman','TypingTest','Simon','RockPaperScissors','Dungeon','SlangBot',
|
|
2706
|
+
],
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
module.exports = { kitdef };
|