เกมงูและ Tetris จอเขียวแบบ Nokia 3310 เล่นในแผงข้าง Claude Code ด้วยปุ่ม w a s d พร้อม High Score

เกมงูและ Tetris จอสีเขียวแบบ Nokia 3310 เล่นในแผงข้าง Claude Code เกมขยับเองตามเวลา และกด w a s d บนคีย์บอร์ดเพื่อบังคับได้เลย High Score จะจำไว้ข้าม session
| คำสั่ง | ทำอะไร |
|---|---|
/game snake [กว้าง] [สูง] | เริ่มเกมงู (ค่าเริ่มต้น 20x12) |
/game tetris | เริ่ม Tetris |
/game pause / resume / quit | หยุด / เล่นต่อ / ออก |
/game scores | ดู High Score (/game scores clear ล้าง) |
/game demo | ดูหน้าจอตัวอย่างแบบข้อความ |
/game up / down / left / right / drop | บังคับด้วยคำสั่ง (ใช้ตอนกดปุ่มในแผงไม่ได้) |
ปุ่มในแผง (กดบนคีย์บอร์ดได้ทันที):
| ปุ่ม | เกมงู | Tetris |
|---|---|---|
w | ขึ้น | หมุน |
a | ซ้าย | เลื่อนซ้าย |
s | ลง | ลงเร็ว |
d | ขวา | เลื่อนขวา |
x | - | ทิ้งลงสุด |
p | หยุด / เล่นต่อ | หยุด / เล่นต่อ |
r | เริ่มใหม่ | เริ่มใหม่ |
กด Esc เพื่อปิดแผง (เกมจะหยุดไว้ พิมพ์ /game resume เพื่อเล่นต่อ)
/plugin install nokia-games-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
$.clock.every และเก็บ High Score ใน $.storeBox (สีพื้น + ขอบ) และทำปุ่มคีย์ลัดด้วย Button hotkey/game และการบังคับเกมด้วยคำสั่งw a s d ใช้ได้เมื่อแผงเกมถูกโฟกัสอยู่ (แผงจะถูกโฟกัสเองตอนเริ่มเกม) ถ้ากลับไปพิมพ์ที่ช่องข้อความ ให้คลิกที่แผงหรือกด ctrl+x แล้ว tab เพื่อกลับไปที่แผงhooks/register.tsx 433 lines1/**
2 * ============================================================================
3 * MOD 13: Nokia Games TH - เกมงูและ Tetris จอเขียวแบบ Nokia 3310 (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: เกม Snake บนมือถือ Nokia 3310 และ Tetris
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /game snake เล่นเกมงู, /game tetris เล่น Tetris ในแผงข้าง Claude Code
9 * 2. จอสีเขียวแบบ Nokia (#9bbc0f) ตัวหนังสือเขียวเข้ม (#0f380f)
10 * 3. เกมขยับเองด้วย timer และควบคุมด้วยปุ่ม w a s d บนคีย์บอร์ด
11 * 4. เก็บ High Score ข้าม session ด้วย $.store
12 *
13 * แนวคิดสำคัญ (สอนทำเกม):
14 * - Game state = ข้อมูลล้วนๆ (ตำแหน่งงู, กระดาน) ไม่ผูกกับการวาด
15 * - Game loop = $.clock.every เรียก tick() ทุกช่วงเวลา แล้วสั่งวาดใหม่
16 * - Input = ปุ่ม (Button) ที่มี hotkey -> กดตัวอักษรบนคีย์บอร์ดได้เลยเมื่อแผงถูกโฟกัส
17 */
18
19import type { EngineInterface, Register, Timer } from 'claude-code'
20
21const PANE = 'nokia-games-th'
22const NOKIA_SCREEN = '#9bbc0f' // สีพื้นจอ Nokia 3310
23const NOKIA_INK = '#0f380f' // สีตัวหนังสือ/พิกเซล
24
25type Point = { x: number; y: number }
26type Direction = 'up' | 'down' | 'left' | 'right'
27
28// ==========================================================================
29// บทที่ 1: Game state ของเกมงู
30// ==========================================================================
31interface SnakeGame {
32 kind: 'snake'
33 width: number
34 height: number
35 snake: Point[] // ตัวแรก = หัว
36 direction: Direction
37 nextDirection: Direction // ทิศที่กดไว้ ใช้ตอน tick ถัดไป (กันกดสองทีแล้วหันกลับชนตัวเอง)
38 food: Point
39 score: number
40 isOver: boolean
41}
42
43function randomCell(width: number, height: number, avoid: Point[]): Point {
44 // สุ่มจนกว่าจะได้ช่องที่ไม่ทับตัวงู
45 for (;;) {
46 const p = { x: Math.floor(Math.random() * width), y: Math.floor(Math.random() * height) }
47 if (!avoid.some(a => a.x === p.x && a.y === p.y)) return p
48 }
49}
50
51function createSnake(width = 20, height = 12): SnakeGame {
52 const cx = Math.floor(width / 2)
53 const cy = Math.floor(height / 2)
54 const snake = [{ x: cx, y: cy }, { x: cx - 1, y: cy }, { x: cx - 2, y: cy }]
55 return { kind: 'snake', width, height, snake, direction: 'right', nextDirection: 'right', food: randomCell(width, height, snake), score: 0, isOver: false }
56}
57
58const OPPOSITE: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' }
59
60// ==========================================================================
61// บทที่ 2: ขยับงูหนึ่งก้าว (เรียกทุก tick)
62// ==========================================================================
63function tickSnake(g: SnakeGame): void {
64 // ห้ามหันกลับ 180 องศา
65 if (g.nextDirection !== OPPOSITE[g.direction]) g.direction = g.nextDirection
66
67 const head = g.snake[0] ?? { x: 0, y: 0 }
68 const next = { ...head }
69 if (g.direction === 'up') next.y -= 1
70 if (g.direction === 'down') next.y += 1
71 if (g.direction === 'left') next.x -= 1
72 if (g.direction === 'right') next.x += 1
73
74 // ชนขอบ หรือชนตัวเอง (ไม่นับหางที่กำลังจะขยับออก) = จบเกม
75 const body = g.snake.slice(0, -1)
76 const hitWall = next.x < 0 || next.x >= g.width || next.y < 0 || next.y >= g.height
77 if (hitWall || body.some(s => s.x === next.x && s.y === next.y)) {
78 g.isOver = true
79 return
80 }
81
82 g.snake.unshift(next)
83 if (next.x === g.food.x && next.y === g.food.y) {
84 g.score += 10 // กินอาหาร: ไม่ตัดหาง งูจึงยาวขึ้น
85 g.food = randomCell(g.width, g.height, g.snake)
86 } else {
87 g.snake.pop()
88 }
89}
90
91// วาดเป็นบรรทัดข้อความ ช่องละ 2 ตัวอักษรให้ดูเป็นสี่เหลี่ยมจัตุรัส
92function drawSnake(g: SnakeGame): string[] {
93 const rows: string[] = []
94 for (let y = 0; y < g.height; y++) {
95 let row = ''
96 for (let x = 0; x < g.width; x++) {
97 const at = g.snake.findIndex(s => s.x === x && s.y === y)
98 row += at === 0 ? '██' : at > 0 ? '▓▓' : g.food.x === x && g.food.y === y ? '<>' : ' '
99 }
100 rows.push(row)
101 }
102 return rows
103}
104
105// ==========================================================================
106// บทที่ 3: Game state ของ Tetris
107// ==========================================================================
108const SHAPES: number[][][] = [
109 [[1, 1, 1, 1]], // I
110 [[1, 1], [1, 1]], // O
111 [[1, 1, 1], [0, 1, 0]], // T
112 [[1, 1, 0], [0, 1, 1]], // S
113 [[0, 1, 1], [1, 1, 0]], // Z
114 [[1, 1, 1], [1, 0, 0]], // J
115 [[1, 1, 1], [0, 0, 1]], // L
116]
117
118interface Piece {
119 shape: number[][]
120 x: number
121 y: number
122}
123
124interface TetrisGame {
125 kind: 'tetris'
126 width: number
127 height: number
128 board: number[][] // 1 = มีบล็อก
129 piece: Piece
130 score: number
131 lines: number
132 isOver: boolean
133}
134
135function randomPiece(width: number): Piece {
136 const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)] ?? [[1]]
137 return { shape, x: Math.floor((width - (shape[0]?.length ?? 1)) / 2), y: 0 }
138}
139
140function createTetris(): TetrisGame {
141 const width = 10
142 const height = 16
143 return { kind: 'tetris', width, height, board: Array.from({ length: height }, () => Array<number>(width).fill(0)), piece: randomPiece(width), score: 0, lines: 0, isOver: false }
144}
145
146// ชิ้นนี้วางที่ตำแหน่งนี้ได้ไหม (ไม่ทะลุขอบ ไม่ทับบล็อกเดิม)
147function fits(g: TetrisGame, p: Piece): boolean {
148 return p.shape.every((row, dy) =>
149 row.every((cell, dx) => {
150 if (!cell) return true
151 const x = p.x + dx
152 const y = p.y + dy
153 return x >= 0 && x < g.width && y < g.height && (y < 0 || !g.board[y]?.[x])
154 }),
155 )
156}
157
158// หมุน 90 องศาตามเข็มนาฬิกา: แถวกลายเป็นคอลัมน์
159function rotate(shape: number[][]): number[][] {
160 const first = shape[0] ?? []
161 return first.map((_, x) => shape.map(row => row[x] ?? 0).reverse())
162}
163
164// ==========================================================================
165// บทที่ 4: Tetris - ตกหนึ่งช่อง ถ้าตกไม่ได้ให้วางลงกระดานแล้วลบแถวที่เต็ม
166// ==========================================================================
167function tickTetris(g: TetrisGame): void {
168 const down = { ...g.piece, y: g.piece.y + 1 }
169 if (fits(g, down)) {
170 g.piece = down
171 return
172 }
173
174 // วางชิ้นลงกระดาน
175 g.piece.shape.forEach((row, dy) =>
176 row.forEach((cell, dx) => {
177 const boardRow = g.board[g.piece.y + dy]
178 if (cell && boardRow) boardRow[g.piece.x + dx] = 1
179 }),
180 )
181
182 // ลบแถวที่เต็ม แล้วเติมแถวว่างด้านบน
183 const kept = g.board.filter(row => row.some(cell => !cell))
184 const cleared = g.height - kept.length
185 g.board = [...Array.from({ length: cleared }, () => Array<number>(g.width).fill(0)), ...kept]
186 g.lines += cleared
187 g.score += [0, 40, 100, 300, 1200][cleared] ?? 0
188
189 // ชิ้นใหม่วางไม่ได้ตั้งแต่บนสุด = จบเกม
190 g.piece = randomPiece(g.width)
191 if (!fits(g, g.piece)) g.isOver = true
192}
193
194function moveTetris(g: TetrisGame, action: 'left' | 'right' | 'rotate' | 'down' | 'drop'): void {
195 if (action === 'drop') {
196 while (fits(g, { ...g.piece, y: g.piece.y + 1 })) g.piece = { ...g.piece, y: g.piece.y + 1 }
197 tickTetris(g)
198 return
199 }
200 if (action === 'down') return tickTetris(g)
201 const moved: Piece =
202 action === 'rotate' ? { ...g.piece, shape: rotate(g.piece.shape) } : { ...g.piece, x: g.piece.x + (action === 'left' ? -1 : 1) }
203 if (fits(g, moved)) g.piece = moved
204}
205
206function drawTetris(g: TetrisGame): string[] {
207 return g.board.map((row, y) =>
208 row
209 .map((cell, x) => {
210 const py = y - g.piece.y
211 const px = x - g.piece.x
212 const isPiece = g.piece.shape[py]?.[px] === 1
213 return cell || isPiece ? '[]' : ' .'
214 })
215 .join(''),
216 )
217}
218
219// ==========================================================================
220// บทที่ 5: Game loop + High Score
221// ==========================================================================
222type Game = SnakeGame | TetrisGame
223type Score = { game: string; score: number; date: number }
224
225let game: Game | undefined
226let isPaused = false
227let loop: Timer | undefined
228let scores: Score[] = []
229
230const SPEED_MS = { snake: 220, tetris: 600 }
231
232function stopLoop(): void {
233 loop?.cancel()
234 loop = undefined
235}
236
237function startLoop($: EngineInterface): void {
238 stopLoop()
239 if (!game) return
240 loop = $.clock.every(SPEED_MS[game.kind], () => {
241 if (!game || isPaused || game.isOver) return
242 if (game.kind === 'snake') tickSnake(game)
243 else tickTetris(game)
244 if (game.isOver) void finish($, game)
245 $.ui.invalidate('ui.render')
246 })
247}
248
249async function finish($: EngineInterface, g: Game): Promise<void> {
250 stopLoop()
251 const best = scores.filter(s => s.game === g.kind).reduce((m, s) => Math.max(m, s.score), 0)
252 scores = [...scores, { game: g.kind, score: g.score, date: await $.clock.now() }].sort((a, b) => b.score - a.score).slice(0, 30)
253 await $.store.set('scores', scores)
254 $.ui.toast(g.score > best && g.score > 0 ? `🏆 สถิติใหม่! ${g.kind} ${g.score} คะแนน` : `💀 GAME OVER - ${g.score} คะแนน`)
255}
256
257async function startGame($: EngineInterface, next: Game): Promise<string> {
258 game = next
259 isPaused = false
260 // focus: true = แผงรับคีย์บอร์ด ปุ่ม hotkey w a s d จึงใช้ได้ทันที
261 const opened = await $.ui.open({ id: PANE, title: '📱 NOKIA 3310', focus: true, closeOnEscape: true })
262 startLoop($)
263 $.ui.invalidate('ui.render')
264 return opened.isPlaced ? '' : `\n(เปิดแผงไม่ได้ในหน้าจอนี้: ${opened.reason})`
265}
266
267// รับคำสั่งควบคุม ใช้ทั้งจากปุ่มในแผงและจากคำสั่ง /game up
268function control(action: string): void {
269 if (!game || game.isOver) return
270 if (game.kind === 'snake') {
271 const map: Record<string, Direction> = { up: 'up', w: 'up', down: 'down', s: 'down', left: 'left', a: 'left', right: 'right', d: 'right' }
272 const dir = map[action]
273 if (dir) game.nextDirection = dir
274 return
275 }
276 const map: Record<string, 'left' | 'right' | 'rotate' | 'down' | 'drop'> = { left: 'left', a: 'left', right: 'right', d: 'right', up: 'rotate', w: 'rotate', rotate: 'rotate', down: 'down', s: 'down', drop: 'drop', x: 'drop' }
277 const move = map[action]
278 if (move && !isPaused) moveTetris(game, move)
279 if (game.isOver) stopLoop()
280}
281
282function scoreBoard(): string {
283 if (scores.length === 0) return '🏆 ยังไม่มี High Score เล่นด้วย /game snake หรือ /game tetris'
284 const lines = ['🏆 High Scores']
285 for (const kind of ['snake', 'tetris']) {
286 const top = scores.filter(s => s.game === kind).slice(0, 5)
287 if (top.length === 0) continue
288 lines.push(`\n🎮 ${kind}:`)
289 top.forEach((s, i) => lines.push(` ${i + 1}. ${s.score} คะแนน - ${new Date(s.date).toLocaleDateString('th-TH')}`))
290 }
291 return lines.join('\n')
292}
293
294const HELP = `📱 Nokia Games TH (Mod 13)
295========================================
296/game snake [กว้าง] [สูง] เกมงู (ค่าเริ่มต้น 20x12)
297/game tetris Tetris
298/game pause | resume | quit
299/game scores [clear] High Score
300/game demo ดูหน้าจอตัวอย่างแบบข้อความ
301
302ในแผง (กดบนคีย์บอร์ดได้เลย):
303 งู: w ขึ้น a ซ้าย s ลง d ขวา
304 Tetris: a ซ้าย d ขวา w หมุน s ลงเร็ว x ทิ้งลงสุด
305 p หยุด/เล่นต่อ r เริ่มใหม่ Esc ปิดแผง
306หรือใช้คำสั่ง /game up | down | left | right | drop`
307
308export const register: Register = on => {
309 on('session.start', async ($, e, next) => {
310 const saved = await $.store.get('scores')
311 scores = Array.isArray(saved) ? (saved as Score[]) : []
312 await $.command.register({ name: 'game', description: 'เกม Nokia: /game snake, /game tetris (/game help)', argumentHint: 'snake|tetris|scores|help' })
313 return next(e)
314 })
315
316 // ปิดแผง (Esc) = หยุดเกม จะได้ไม่มี timer วิ่งค้าง
317 on('ui.close', ($, e, next) => {
318 if (e.id === PANE && game && !game.isOver) {
319 isPaused = true
320 stopLoop()
321 }
322 return next(e)
323 })
324
325 // ==========================================================================
326 // บทที่ 6: วาดจอ Nokia + ปุ่มควบคุมที่มี hotkey
327 // ==========================================================================
328 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
329 const { Box, Text, Button } = $.ui.resolve(e)
330 if (!game) return <Text dimColor>เริ่มเกมด้วย /game snake หรือ /game tetris</Text>
331
332 const g = game
333 const rows = g.kind === 'snake' ? drawSnake(g) : drawTetris(g)
334 const status = g.isOver ? '💀 GAME OVER (r = เล่นใหม่)' : isPaused ? '⏸ หยุดอยู่ (p = เล่นต่อ)' : g.kind === 'snake' ? `ยาว ${g.snake.length}` : `${g.lines} แถว`
335 const press = (action: string) => () => {
336 control(action)
337 $.ui.invalidate('ui.render')
338 }
339 const restart = () => void startGame($, g.kind === 'snake' ? createSnake(g.width, g.height) : createTetris())
340 const togglePause = () => {
341 if (g.isOver) return
342 isPaused = !isPaused
343 $.ui.invalidate('ui.render')
344 }
345
346 return (
347 <Box flexDirection="column">
348 <Box flexDirection="column" backgroundColor={NOKIA_SCREEN} borderStyle="bold" borderColor={NOKIA_INK} paddingX={1}>
349 <Text color={NOKIA_INK} bold>
350 {`NOKIA ${g.kind.toUpperCase()} SCORE ${g.score}`}
351 </Text>
352 {rows.map(row => (
353 <Text color={NOKIA_INK}>{row}</Text>
354 ))}
355 <Text color={NOKIA_INK}>{status}</Text>
356 </Box>
357 <Box gap={1} flexWrap="wrap">
358 <Button key="w" hotkey="w" label={g.kind === 'snake' ? '↑' : '⟳'} onPress={press('w')} />
359 <Button key="a" hotkey="a" label="←" onPress={press('a')} />
360 <Button key="s" hotkey="s" label="↓" onPress={press('s')} />
361 <Button key="d" hotkey="d" label="→" onPress={press('d')} />
362 {g.kind === 'tetris' && <Button key="x" hotkey="x" label="ทิ้ง" onPress={press('x')} />}
363 <Button key="p" hotkey="p" label={isPaused ? 'เล่นต่อ' : 'หยุด'} onPress={togglePause} />
364 <Button key="r" hotkey="r" label="ใหม่" onPress={restart} />
365 </Box>
366 </Box>
367 )
368 })
369
370 // ==========================================================================
371 // บทที่ 7: คำสั่ง /game
372 // ==========================================================================
373 on('command.run', { command: 'game' }, async ($, e) => {
374 const [sub = 'help', a1, a2] = e.args.trim().split(/\s+/).filter(Boolean)
375
376 switch (sub) {
377 case 'snake': {
378 const width = Math.min(30, Math.max(8, Number(a1) || 20))
379 const height = Math.min(20, Math.max(6, Number(a2) || 12))
380 return { text: `🐍 เริ่มเกมงู ${width}x${height} กด w a s d ในแผงเพื่อบังคับ${await startGame($, createSnake(width, height))}` }
381 }
382 case 'tetris':
383 return { text: `🧱 เริ่ม Tetris กด a d เลื่อน, w หมุน, x ทิ้งลงสุด${await startGame($, createTetris())}` }
384 case 'pause':
385 isPaused = true
386 $.ui.invalidate('ui.render')
387 return { text: '⏸️ หยุดเกมแล้ว' }
388 case 'resume':
389 if (!game || game.isOver) return { text: 'ไม่มีเกมที่เล่นค้างอยู่' }
390 isPaused = false
391 if (!loop) startLoop($)
392 await $.ui.open({ id: PANE, title: '📱 NOKIA 3310', focus: true, closeOnEscape: true })
393 return { text: '▶️ เล่นต่อ' }
394 case 'quit':
395 stopLoop()
396 game = undefined
397 await $.ui.close({ id: PANE })
398 return { text: '👋 ออกจากเกมแล้ว' }
399 case 'scores':
400 if (a1 === 'clear') {
401 scores = []
402 await $.store.delete('scores')
403 return { text: '🧹 ล้าง High Score แล้ว' }
404 }
405 return { text: scoreBoard() }
406 case 'demo': {
407 const demo = createSnake(16, 6)
408 for (let i = 0; i < 3; i++) tickSnake(demo)
409 return { text: ['📱 NOKIA 3310 - ตัวอย่างจอเกมงู', ...drawSnake(demo).map(r => `|${r}|`), '/game snake เพื่อเล่นจริง'].join('\n') }
410 }
411 case 'up':
412 case 'down':
413 case 'left':
414 case 'right':
415 case 'drop':
416 control(sub)
417 $.ui.invalidate('ui.render')
418 return { text: `🎮 ${sub}` }
419 default:
420 return { text: HELP }
421 }
422 })
423}
424
425/**
426 * ========================================================================
427 * การบ้าน / ต่อยอด
428 * ========================================================================
429 * 1. ทำให้งูเร็วขึ้นทุก 50 คะแนน: เรียก startLoop ใหม่ด้วยค่า SPEED_MS ที่น้อยลง
430 * 2. วาดด้วย Raster (terminal) แทนข้อความ จะได้พิกเซลสีแบบจอจริง
431 * 3. ให้ Claude เล่นงูเอง: หาทางไปอาหารด้วย BFS แล้วเรียก control() ทุก tick
432 */
433