ฉลองความสำเร็จ: เทสต์ผ่าน commit สำเร็จ หรือ deploy เสร็จ จะมีพลุในแผง เสียงแตร และสถิติความสำเร็จ

ฉลองความสำเร็จด้วยพลุกระดาษที่ตกลงมาในแผงและเสียงแตรที่สังเคราะห์ขึ้นเอง ฉลองอัตโนมัติเมื่อ Claude รันคำสั่งแล้วสำเร็จจริง: เทสต์ผ่าน (npm/pnpm/yarn test, pytest, jest, vitest, go test, cargo test ...), git commit สำเร็จ และ deploy สำเร็จ พร้อมสถิติ
| คำสั่ง | ทำอะไร | |
|---|---|---|
/celebrate [ข้อความ] | พลุกระดาษ + แตรฉลอง | |
| `/celebrate auto on\ | off` | ฉลองอัตโนมัติ |
| `/celebrate sound on\ | off` | เปิด/ปิดเสียง |
/celebrate stats | สถิติความสำเร็จ | |
| `/celebrate style fireworks\ | confetti` | พลุไฟ (ค่าเริ่มต้น: จรวดพุ่งแล้วระเบิดเป็นประกายสี) หรือพลุกระดาษ |
/plugin install celebrate-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
เสียงใน sounds/ สังเคราะห์ขึ้นเองด้วย Python บน Windows/Linux เล่นผ่านโปรแกรมของระบบ (PowerShell SoundPlayer / paplay / aplay) แบบเดียวกับ Mod 36 การฉลองอัตโนมัติดูจากคำสั่งที่ Claude รันเท่านั้น ไม่รวมคำสั่งที่คุณรันเองใน terminal
hooks/register.tsx 263 lines1/**
2 * ============================================================================
3 * MOD 45: Celebrate TH - ฉลองความสำเร็จ พลุกระดาษ + แตรฉลอง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: เอฟเฟกต์พลุตอนปิดงานใน Asana และ Linear
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /celebrate - พลุกระดาษ (confetti) ตกลงมาในแผง พร้อมเสียงแตรที่สังเคราะห์เอง
9 * 2. ฉลองอัตโนมัติเมื่อ Claude รันคำสั่งแล้วสำเร็จจริง:
10 * เทสต์ผ่าน / git commit สำเร็จ / deploy สำเร็จ (ดูจากผลลัพธ์ Bash)
11 * 3. สถิติความสำเร็จ: วันนี้ / ทั้งหมด
12 * 4. พลุไฟ (fireworks): จรวดพุ่งขึ้นแล้วระเบิดเป็นประกายสีตกตามแรงโน้มถ่วง วาดด้วย Raster/SVG (grid.tsx)
13 * เลือกแบบได้ /celebrate style fireworks|confetti
14 *
15 * เรื่องเสียง: $.audio.play เล่นได้บน macOS แต่ terminal ของ Windows/Linux ไม่มีเครื่องเล่น
16 * จึงเล่นผ่านโปรแกรมของระบบแทน (PowerShell SoundPlayer / paplay / aplay) แบบเดียวกับ Mod 36
17 */
18
19import type { EngineInterface, Register, Timer } from 'claude-code'
20import { Grid, drawGrid, hsl, rgb } from './grid'
21
22type Kind = 'test' | 'commit' | 'deploy' | 'manual'
23
24// ==========================================================================
25// พลุไฟ: จรวด 5 ลูกยิงห่างกัน ถึงจุดสูงสุดแล้วระเบิดเป็นประกาย 28 ชิ้น
26// ทุกอย่างคำนวณจากเลขเฟรม (ไม่สุ่มตอนวาด) จึงเทสต์ได้และภาพไม่กระตุก
27// ==========================================================================
28const FW_FRAMES = 48
29const FW_W = 60
30const FW_H = 16
31
32function fireworks(f: number): Grid {
33 const g = new Grid(FW_W, FW_H, rgb(5, 6, 20))
34 for (let k = 0; k < 5; k++) {
35 const start = k * 6
36 const age = f - start
37 if (age < 0) continue
38 const seed = Math.sin(k * 91.7) * 0.5 + 0.5
39 const x = 6 + ((k * 13 + Math.round(seed * 9)) % (FW_W - 12))
40 const peak = 3 + Math.round(seed * 4)
41 const rise = 7
42 const hue = (k * 77 + 20) % 360
43 if (age < rise) {
44 // จรวดกำลังพุ่งขึ้น + หางไฟ
45 const y = FW_H - 1 - ((FW_H - 1 - peak) * age) / rise
46 g.set(x, y, '^', rgb(255, 240, 200))
47 g.set(x, y + 1, '|', rgb(255, 160, 60))
48 continue
49 }
50 const t = age - rise
51 const life = 1 - t / 20
52 if (life <= 0) continue
53 for (let p = 0; p < 28; p++) {
54 const a = (p / 28) * Math.PI * 2
55 const speed = 0.9 + (p % 3) * 0.35
56 // แกน x คูณ 2 เพราะช่องตัวอักษรสูงกว่ากว้าง ประกายจะได้เป็นวงกลม
57 const px = x + Math.cos(a) * speed * t * 1.6
58 const py = peak + Math.sin(a) * speed * t * 0.8 + 0.035 * t * t
59 g.set(px, py, life > 0.6 ? '*' : life > 0.3 ? '+' : '.', hsl(hue + (p % 4) * 12, 0.95, 0.35 + life * 0.35))
60 }
61 }
62 return g
63}
64
65const PANE = 'celebrate-th'
66const COLORS = ['#ef5350', '#ffca28', '#66bb6a', '#42a5f5', '#ab47bc', '#ff7043', '#26c6da']
67const PIECES = ['▪', '●', '▲', '✦', '❖', '♦', '★', '✿']
68const FRAMES = 28
69const WIDTH = 44
70const HEIGHT = 9
71
72const KIND_TEXT: Record<Kind, string> = {
73 test: '🧪 เทสต์ผ่านหมด!',
74 commit: '📦 commit สำเร็จ!',
75 deploy: '🚀 deploy สำเร็จ!',
76 manual: '🎉 เย้!',
77}
78
79interface Stats {
80 total: number
81 byKind: Record<Kind, number>
82 day: string
83 today: number
84}
85
86let stats: Stats = { total: 0, byKind: { test: 0, commit: 0, deploy: 0, manual: 0 }, day: '', today: 0 }
87let settings = { auto: true, sound: true, style: 'fireworks' as 'fireworks' | 'confetti' }
88let frame = FRAMES
89let headline = ''
90let animator: Timer | undefined
91let isWindows = false
92let isMac = false
93
94// ==========================================================================
95// บทที่ 1: แยกแยะว่าคำสั่ง Bash ที่เพิ่งรัน "สำเร็จ" แบบไหน
96// ==========================================================================
97function detect(command: string, output: string): Kind | undefined {
98 const failed = /\b[1-9]\d*\s+(?:failed|failing|failures?|errors?)\b|\bFAILED\b|✘|\(fail\)/i.test(output)
99 if (/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bpytest\b|\bjest\b|\bvitest\b|\bgo\s+test\b|\bcargo\s+test\b|\bphpunit\b|\bplugin\s+test\b/.test(command)) {
100 return failed ? undefined : 'test'
101 }
102 // git commit สำเร็จจะพิมพ์ "[branch abc1234] ข้อความ"
103 if (/\bgit\s+commit\b/.test(command) && /^\[[^\]\n]+ [0-9a-f]{7,}\]/m.test(output)) return 'commit'
104 if (/\bdeploy\b|\bvercel\b.*--prod|\bfly\s+deploy\b|\bwrangler\s+(?:deploy|publish)\b/.test(command) && !failed && !/\berror\b/i.test(output)) return 'deploy'
105 return undefined
106}
107
108// ==========================================================================
109// บทที่ 2: พลุกระดาษ - ตำแหน่งแต่ละชิ้นคำนวณจาก "เลขเฟรม" (สุ่มแบบคงที่ เทสต์ได้)
110// ==========================================================================
111function rand(n: number): number {
112 const x = Math.sin(n * 12.9898) * 43758.5453
113 return x - Math.floor(x)
114}
115
116function confetti(f: number): { text: string; color: string }[][] {
117 const rows: { text: string; color: string }[][] = []
118 for (let y = 0; y < HEIGHT; y++) {
119 const row: { text: string; color: string }[] = []
120 for (let x = 0; x < WIDTH; x++) {
121 // แต่ละคอลัมน์มีชิ้นหนึ่งตกลงด้วยความเร็วและจังหวะเริ่มต่างกัน
122 const speed = 0.4 + rand(x) * 0.6
123 const fall = Math.floor((f - rand(x + 99) * 10) * speed)
124 const hit = fall === y || (rand(x * 7 + y) > 0.97 && f < FRAMES - 6)
125 row.push(hit ? { text: PIECES[Math.floor(rand(x + y + f) * PIECES.length)] ?? '●', color: COLORS[(x + y) % COLORS.length] ?? '#fff' } : { text: ' ', color: '' })
126 }
127 rows.push(row)
128 }
129 return rows
130}
131
132function play($: EngineInterface, name: string): void {
133 if (!settings.sound) return
134 if (isMac) {
135 void $.audio.play({ asset: `sounds/${name}.wav` }).catch(() => undefined)
136 return
137 }
138 const file = `${$.plugin.root}${isWindows ? '\\sounds\\' : '/sounds/'}${name}.wav`
139 const argv = isWindows
140 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', '(New-Object Media.SoundPlayer $env:CB_FILE).PlaySync()']
141 : ['sh', '-c', 'paplay "$CB_FILE" 2>/dev/null || aplay -q "$CB_FILE"']
142 void $.process.run(argv, { env: { CB_FILE: file }, timeoutMs: 10_000 }).catch(() => undefined)
143}
144
145async function celebrate($: EngineInterface, kind: Kind, message: string, big: boolean): Promise<void> {
146 const now = new Date(await $.clock.now())
147 const day = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`
148 if (stats.day !== day) stats = { ...stats, day, today: 0 }
149 stats = { ...stats, total: stats.total + 1, today: stats.today + 1, byKind: { ...stats.byKind, [kind]: stats.byKind[kind] + 1 } }
150 await $.store.set('stats', stats)
151
152 headline = message
153 play($, big ? 'fanfare' : 'ding')
154 $.ui.toast(`${message} (วันนี้ฉลองไป ${stats.today} ครั้ง)`)
155 if (!big) return
156 frame = 0
157 await $.ui.open({ id: PANE, title: '🎉 Celebrate' })
158 animator?.cancel()
159 const total = settings.style === 'fireworks' ? FW_FRAMES : FRAMES
160 animator = $.clock.every(settings.style === 'fireworks' ? 100 : 150, () => {
161 frame++
162 if (frame >= total) {
163 animator?.cancel()
164 animator = undefined
165 }
166 $.ui.invalidate('ui.render')
167 })
168}
169
170const HELP = `🎉 Celebrate TH (Mod 45)
171========================================
172/celebrate [ข้อความ] พลุกระดาษ + แตรฉลอง
173/celebrate auto on|off ฉลองอัตโนมัติเมื่อเทสต์ผ่าน / commit / deploy สำเร็จ (ตอนนี้: AUTO)
174/celebrate sound on|off เปิด/ปิดเสียง (ตอนนี้: SOUND)
175/celebrate style fireworks|confetti พลุไฟ หรือ พลุกระดาษ
176/celebrate stats สถิติความสำเร็จ`
177
178export const register: Register = on => {
179 on('session.start', async ($, e, next) => {
180 isWindows = /^[A-Za-z]:/.test(e.cwd)
181 isMac = !isWindows && e.cwd.startsWith('/Users/')
182 stats = ((await $.store.get('stats')) as Stats | undefined) ?? stats
183 settings = { ...settings, ...((await $.store.get('settings')) as typeof settings | undefined) }
184 await $.command.register({ name: 'celebrate', description: 'ฉลองความสำเร็จ พลุกระดาษ + แตร และฉลองอัตโนมัติเมื่อเทสต์ผ่าน (/celebrate help)', argumentHint: '[ข้อความ]|auto|sound|stats' })
185 return next(e)
186 })
187
188 // ==========================================================================
189 // บทที่ 3: ดูผลลัพธ์ Bash - สำเร็จจริงเท่านั้นถึงฉลอง
190 // ==========================================================================
191 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
192 const r = await next(e)
193 if (!settings.auto || r.deny !== undefined || r.isError) return r
194 const kind = detect(e.command, `${r.result.stdout}\n${r.result.stderr}`)
195 if (kind) void celebrate($, kind, KIND_TEXT[kind], kind !== 'commit')
196 return r
197 })
198
199 // ==========================================================================
200 // บทที่ 4: วาดพลุกระดาษในแผง
201 // ==========================================================================
202 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203 const els = $.ui.resolve(e)
204 const { Box, Text } = els
205 const done = frame >= (settings.style === 'fireworks' ? FW_FRAMES : FRAMES)
206 return (
207 <Box flexDirection="column" alignItems="center">
208 {!done && settings.style === 'fireworks' && (drawGrid(els, e.surface, fireworks(frame), 'fw', 'พลุไฟฉลองความสำเร็จ') as never)}
209 {!done &&
210 settings.style === 'confetti' &&
211 confetti(frame).map(row => (
212 <Text>
213 {row.map(c => (c.color ? <Text color={c.color}>{c.text}</Text> : c.text))}
214 </Text>
215 ))}
216 <Text bold color="#ffca28">
217 {headline || '🎉'}
218 </Text>
219 <Text dimColor>
220 วันนี้ {stats.today} · ทั้งหมด {stats.total} ครั้ง
221 </Text>
222 </Box>
223 )
224 })
225
226 on('command.run', { command: 'celebrate' }, async ($, e) => {
227 const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
228 switch (sub) {
229 case 'help':
230 return { text: HELP.replace('AUTO', settings.auto ? 'เปิด' : 'ปิด').replace('SOUND', settings.sound ? 'เปิด' : 'ปิด') }
231 case 'auto':
232 case 'sound': {
233 if (rest[0] !== 'on' && rest[0] !== 'off') return { text: `วิธีใช้: /celebrate ${sub} on|off` }
234 settings = { ...settings, [sub]: rest[0] === 'on' }
235 await $.store.set('settings', settings)
236 return { text: `${sub === 'auto' ? '🤖 ฉลองอัตโนมัติ' : '🔊 เสียง'}: ${rest[0] === 'on' ? 'เปิด' : 'ปิด'}` }
237 }
238 case 'style': {
239 if (rest[0] !== 'fireworks' && rest[0] !== 'confetti') return { text: `วิธีใช้: /celebrate style fireworks|confetti (ตอนนี้: ${settings.style})` }
240 settings = { ...settings, style: rest[0] }
241 await $.store.set('settings', settings)
242 return { text: rest[0] === 'fireworks' ? '🎆 ใช้พลุไฟ' : '🎊 ใช้พลุกระดาษ' }
243 }
244 case 'stats': {
245 const k = stats.byKind
246 return { text: `🏆 ฉลองไปแล้ว ${stats.total} ครั้ง (วันนี้ ${stats.today})\n 🧪 เทสต์ผ่าน ${k.test}\n 📦 commit ${k.commit}\n 🚀 deploy ${k.deploy}\n 🎉 ฉลองเอง ${k.manual}` }
247 }
248 default:
249 await celebrate($, 'manual', e.args.trim().slice(0, 60) || '🎉 เย้! เก่งมาก!', true)
250 return { text: '🎉🎊✨' }
251 }
252 })
253}
254
255/**
256 * ========================================================================
257 * การบ้าน / ต่อยอด
258 * ========================================================================
259 * 1. ฉลองแบบพิเศษเมื่อครบ 10, 50, 100 ครั้ง
260 * 2. ฉลองเมื่อ PR ถูก merge (เช็คผล gh pr merge)
261 * 3. ส่งพลุไปมือถือด้วย Mod 22 ตอน deploy สำเร็จ
262 */
263hooks/grid.tsx 154 lines1/**
2 * grid.tsx - ตารางตัวอักษรมีสี วาดได้ทุกหน้าจอ
3 * terminal: ใช้ Raster (ช่องสีจริง เร็ว) / desktop, vscode, mobile: แปลงเป็น SVG
4 * ไฟล์นี้ไม่รับ $ (ตัวตรวจของ engine ไม่ให้ส่ง $ ข้ามไฟล์) รับแค่ element ที่ resolve มาแล้ว
5 */
6
7export const DEFAULT = 0x01000000 // สีเริ่มต้นของ terminal
8
9export class Grid {
10 readonly w: number
11 readonly h: number
12 readonly ch: string[]
13 readonly fg: number[]
14 readonly bg: number[]
15
16 constructor(w: number, h: number, bg = DEFAULT) {
17 this.w = w
18 this.h = h
19 this.ch = new Array<string>(w * h).fill(' ')
20 this.fg = new Array<number>(w * h).fill(DEFAULT)
21 this.bg = new Array<number>(w * h).fill(bg)
22 }
23
24 set(x: number, y: number, ch: string, fg = DEFAULT, bg?: number): void {
25 const xi = Math.round(x)
26 const yi = Math.round(y)
27 if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
28 const i = yi * this.w + xi
29 this.ch[i] = ch
30 this.fg[i] = fg
31 if (bg !== undefined) this.bg[i] = bg
32 }
33
34 paint(x: number, y: number, bg: number): void {
35 const xi = Math.round(x)
36 const yi = Math.round(y)
37 if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
38 this.bg[yi * this.w + xi] = bg
39 }
40
41 text(x: number, y: number, s: string, fg = DEFAULT, bg?: number): void {
42 ;[...s].forEach((c, i) => this.set(x + i, y, c, fg, bg))
43 }
44}
45
46// สี RGB เป็นตัวเลข 0xRRGGBB
47export function rgb(r: number, g: number, b: number): number {
48 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
49 return (c(r) << 16) | (c(g) << 8) | c(b)
50}
51
52export function hsl(h: number, s: number, l: number): number {
53 const a = s * Math.min(l, 1 - l)
54 const f = (n: number) => {
55 const k = (n + h / 30) % 12
56 return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
57 }
58 return rgb(f(0) * 255, f(8) * 255, f(4) * 255)
59}
60
61// ==========================================================================
62// Raster ต้องการ base64 ของ u32 little-endian [codePoint, fg, bg] ต่อช่อง
63// ==========================================================================
64const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66function base64(bytes: Uint8Array): string {
67 let out = ''
68 for (let i = 0; i < bytes.length; i += 3) {
69 const a = bytes[i] ?? 0
70 const b = bytes[i + 1] ?? 0
71 const c = bytes[i + 2] ?? 0
72 out += B64[a >> 2] ?? ''
73 out += B64[((a & 3) << 4) | (b >> 4)] ?? ''
74 out += i + 1 < bytes.length ? (B64[((b & 15) << 2) | (c >> 6)] ?? '') : '='
75 out += i + 2 < bytes.length ? (B64[c & 63] ?? '') : '='
76 }
77 return out
78}
79
80export function rasterCells(g: Grid): string {
81 const bytes = new Uint8Array(g.w * g.h * 12)
82 const view = new DataView(bytes.buffer)
83 for (let i = 0; i < g.w * g.h; i++) {
84 view.setUint32(i * 12, (g.ch[i] ?? ' ').codePointAt(0) ?? 32, true)
85 view.setUint32(i * 12 + 4, g.fg[i] ?? DEFAULT, true)
86 view.setUint32(i * 12 + 8, g.bg[i] ?? DEFAULT, true)
87 }
88 return base64(bytes)
89}
90
91// ==========================================================================
92// SVG: รวมช่องที่สีเดียวกันติดกันเป็นก้อนเดียว ให้ไฟล์เล็ก (จำกัด 131,072 ตัวอักษร)
93// ==========================================================================
94const CW = 8.4
95const CH = 16
96
97function hex(c: number, fallback: string): string {
98 return c === DEFAULT ? fallback : `#${c.toString(16).padStart(6, '0')}`
99}
100
101function esc(s: string): string {
102 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
103}
104
105export function svgOf(g: Grid, background = '#0b0e14', foreground = '#d8dee9'): string {
106 const parts: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(g.w * CW)}" height="${g.h * CH}" viewBox="0 0 ${g.w * CW} ${g.h * CH}"><rect width="100%" height="100%" fill="${background}"/>`]
107 for (let y = 0; y < g.h; y++) {
108 // พื้นหลัง
109 let x = 0
110 while (x < g.w) {
111 const c = g.bg[y * g.w + x] ?? DEFAULT
112 let end = x + 1
113 while (end < g.w && g.bg[y * g.w + end] === c) end++
114 if (c !== DEFAULT) parts.push(`<rect x="${(x * CW).toFixed(1)}" y="${y * CH}" width="${((end - x) * CW).toFixed(1)}" height="${CH}" fill="${hex(c, background)}"/>`)
115 x = end
116 }
117 // ตัวอักษร
118 x = 0
119 while (x < g.w) {
120 const i = y * g.w + x
121 if ((g.ch[i] ?? ' ') === ' ') {
122 x++
123 continue
124 }
125 const c = g.fg[i] ?? DEFAULT
126 let end = x + 1
127 while (end < g.w && g.fg[y * g.w + end] === c && (g.ch[y * g.w + end] ?? ' ') !== ' ') end++
128 const run = g.ch.slice(i, y * g.w + end).join('')
129 parts.push(`<text x="${(x * CW).toFixed(1)}" y="${y * CH + 12}" fill="${hex(c, foreground)}" textLength="${((end - x) * CW).toFixed(1)}" lengthAdjust="spacingAndGlyphs">${esc(run)}</text>`)
130 x = end
131 }
132 }
133 parts.push('</svg>')
134 const svg = parts.join('')
135 return svg.replace('<svg ', '<svg font-family="Consolas, Menlo, monospace" font-size="14" xml:space="preserve" ')
136}
137
138// ==========================================================================
139// วาดตามหน้าจอ: els = $.ui.resolve(e), surface = e.surface
140// ==========================================================================
141// eslint-disable-next-line @typescript-eslint/no-explicit-any
142export function drawGrid(els: any, surface: string, g: Grid, key: string, alt: string): unknown {
143 if (surface === 'terminal' && els.Raster) {
144 const Raster = els.Raster
145 return <Raster key={key} columns={g.w} rows={g.h} cells={rasterCells(g)} />
146 }
147 if (els.Svg) {
148 const Svg = els.Svg
149 return <Svg source={svgOf(g)} alt={alt} />
150 }
151 const Text = els.Text
152 return <Text>{alt}</Text>
153}
154