@pavus/snake-game 1.0.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/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from 'ink';
4
+ import { SnakeGame } from './src/SnakeGame.js';
5
+ const app = render(_jsx(SnakeGame, { onExit: () => {
6
+ app.unmount();
7
+ process.exit(0);
8
+ } }));
9
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../bin.tsx"],"names":[],"mappings":";;AACA,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE/C,MAAM,GAAG,GAAG,MAAM,CAChB,KAAC,SAAS,IACR,MAAM,EAAE,GAAG,EAAE;QACX,GAAG,CAAC,OAAO,EAAE,CAAC;QACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,GACD,CACH,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { render } from 'ink';\nimport { SnakeGame } from './src/SnakeGame.js';\n\nconst app = render(\n <SnakeGame\n onExit={() => {\n app.unmount();\n process.exit(0);\n }}\n />,\n);\n"]}
@@ -0,0 +1,184 @@
1
+ /**
2
+ * snake-synth.mjs — General-purpose MIDI → WAV synthesizer for the Snake game.
3
+ *
4
+ * Usage: node snake-synth.mjs <midi-url-or-path> <output.wav>
5
+ * Stdout: JSON { bpm: number, durationMs: number }
6
+ *
7
+ * No hardcoded song data — works with any MIDI file.
8
+ * Uses midi-file (already a project dep) via createRequire for CJS compat.
9
+ */
10
+
11
+ import { createRequire } from 'node:module';
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { parseMidi } = require('midi-file');
16
+
17
+ const [, , midiSrc, wavPath] = process.argv;
18
+ if (!midiSrc || !wavPath) {
19
+ process.stderr.write('Usage: node snake-synth.mjs <midi-url-or-path> <output.wav>\n');
20
+ process.exit(1);
21
+ }
22
+
23
+ // ── Fetch or read MIDI ────────────────────────────────────────────────
24
+
25
+ let midiBuffer;
26
+ if (midiSrc.startsWith('http://') || midiSrc.startsWith('https://')) {
27
+ const res = await fetch(midiSrc);
28
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${midiSrc}`);
29
+ midiBuffer = Buffer.from(await res.arrayBuffer());
30
+ } else {
31
+ midiBuffer = readFileSync(midiSrc);
32
+ }
33
+
34
+ const midi = parseMidi(midiBuffer);
35
+ const TPB = midi.header.ticksPerBeat;
36
+
37
+ // ── Tempo map ─────────────────────────────────────────────────────────
38
+
39
+ const tempoMap = [{ tick: 0, uspb: 500000 }];
40
+ {
41
+ let tick = 0;
42
+ for (const ev of midi.tracks[0]) {
43
+ tick += ev.deltaTime;
44
+ if (ev.type === 'setTempo') tempoMap.push({ tick, uspb: ev.microsecondsPerBeat });
45
+ }
46
+ }
47
+
48
+ function tickToMs(tick) {
49
+ let ms = 0, prevTick = 0, uspb = 500000;
50
+ for (const entry of tempoMap) {
51
+ if (entry.tick >= tick) break;
52
+ ms += ((entry.tick - prevTick) / TPB) * (uspb / 1000);
53
+ prevTick = entry.tick;
54
+ uspb = entry.uspb;
55
+ }
56
+ return ms + ((tick - prevTick) / TPB) * (uspb / 1000);
57
+ }
58
+
59
+ // ── Collect notes with durations ──────────────────────────────────────
60
+
61
+ const notes = [];
62
+
63
+ for (let t = 0; t < midi.tracks.length; t++) {
64
+ // Reset active map per track — tick counters are independent between tracks
65
+ // and a shared map causes cross-track key collisions with wrong end times.
66
+ const active = new Map();
67
+ let tick = 0;
68
+ let lastTick = 0;
69
+
70
+ for (const ev of midi.tracks[t]) {
71
+ tick += ev.deltaTime;
72
+ lastTick = tick;
73
+ if (ev.channel === 9) continue; // skip GM percussion channel
74
+ const key = `${ev.channel}-${ev.noteNumber}`;
75
+ if (ev.type === 'noteOn' && ev.velocity > 0) {
76
+ // A second noteOn on same pitch retriggers — close the previous instance first.
77
+ const prev = active.get(key);
78
+ if (prev) notes.push({ startMs: tickToMs(prev.startTick), endMs: tickToMs(tick), note: ev.noteNumber, velocity: prev.velocity, track: t });
79
+ active.set(key, { startTick: tick, velocity: ev.velocity, note: ev.noteNumber });
80
+ } else if (ev.type === 'noteOff' || (ev.type === 'noteOn' && ev.velocity === 0)) {
81
+ const a = active.get(key);
82
+ if (a) {
83
+ notes.push({ startMs: tickToMs(a.startTick), endMs: tickToMs(tick), note: ev.noteNumber, velocity: a.velocity, track: t });
84
+ active.delete(key);
85
+ }
86
+ }
87
+ }
88
+
89
+ // Flush orphaned notes (noteOn with no matching noteOff) at track end.
90
+ for (const a of active.values()) {
91
+ notes.push({ startMs: tickToMs(a.startTick), endMs: tickToMs(lastTick), note: a.note, velocity: a.velocity, track: t });
92
+ }
93
+ }
94
+
95
+ // Find the first track that actually has pitched notes — that's the lead melody.
96
+ // (In format-1 MIDI, track 0 is always tempo metadata and has no noteOns, but
97
+ // some files have multiple empty preamble tracks before the first instrument.)
98
+ const LEAD_TRACK = (() => {
99
+ for (let t = 0; t < midi.tracks.length; t++) {
100
+ if (midi.tracks[t].some((e) => e.type === 'noteOn' && e.velocity > 0 && e.channel !== 9)) {
101
+ return t;
102
+ }
103
+ }
104
+ return -1;
105
+ })();
106
+
107
+ // ── Synthesize ────────────────────────────────────────────────────────
108
+
109
+ const totalMs = Math.max(...notes.map((n) => n.endMs)) + 1000;
110
+ const SR = 22050;
111
+ const totalSamples = Math.ceil((totalMs / 1000) * SR);
112
+ const mix = new Float32Array(totalSamples);
113
+
114
+ function midiToFreq(n) { return 440 * Math.pow(2, (n - 69) / 12); }
115
+
116
+ // Three detuned oscillators per note: center, −5 cents, +5 cents
117
+ // Gives a warm chorus/unison effect without external effects processing.
118
+ const DETUNE_CENTS = [-5, 0, 5];
119
+ const DETUNE_RATIOS = DETUNE_CENTS.map((c) => Math.pow(2, c / 1200));
120
+ const VOICE_AMP = 1 / DETUNE_RATIOS.length; // normalize across voices
121
+
122
+ for (const { startMs, endMs, note, velocity, track } of notes) {
123
+ const startS = Math.floor((startMs / 1000) * SR);
124
+ const durS = Math.max(Math.floor(((endMs - startMs) / 1000) * SR), 1);
125
+ const rel = Math.min(Math.floor(SR * 0.20), durS); // longer release for smoother decay
126
+ const atk = Math.min(Math.floor(SR * 0.010), durS);
127
+ const leadBoost = track === LEAD_TRACK ? 1.5 : 1.0;
128
+ const amp = (velocity / 127) * 0.12 * VOICE_AMP * leadBoost;
129
+ const baseFreq = midiToFreq(note);
130
+ const end = Math.min(startS + durS + rel, totalSamples);
131
+
132
+ for (const ratio of DETUNE_RATIOS) {
133
+ const freq = baseFreq * ratio;
134
+ for (let i = startS; i < end; i++) {
135
+ const pos = i - startS;
136
+ const env = Math.min(pos / atk, 1) * (pos < durS ? 1 : (durS + rel - pos) / rel);
137
+ const t = pos / SR;
138
+ mix[i] += amp * env * (
139
+ Math.sin(2 * Math.PI * freq * t) +
140
+ Math.sin(2 * Math.PI * freq * 2 * t) * 0.40 +
141
+ Math.sin(2 * Math.PI * freq * 3 * t) * 0.20 +
142
+ Math.sin(2 * Math.PI * freq * 4 * t) * 0.12 +
143
+ Math.sin(2 * Math.PI * freq * 5 * t) * 0.07
144
+ );
145
+ }
146
+ }
147
+ }
148
+
149
+ let peak = 0;
150
+ for (const s of mix) if (Math.abs(s) > peak) peak = Math.abs(s);
151
+ const scale = peak > 0 ? 0.9 / peak : 1;
152
+
153
+ // ── Write WAV (16-bit mono, 22050 Hz) ────────────────────────────────
154
+
155
+ const dataSize = totalSamples * 2;
156
+ const wav = Buffer.alloc(44 + dataSize);
157
+ wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVE', 8);
158
+ wav.write('fmt ', 12); wav.writeUInt32LE(16, 16);
159
+ wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
160
+ wav.writeUInt32LE(SR, 24); wav.writeUInt32LE(SR * 2, 28);
161
+ wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
162
+ wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
163
+ for (let i = 0; i < totalSamples; i++)
164
+ wav.writeInt16LE(Math.round(Math.max(-32768, Math.min(32767, mix[i] * scale * 32767))), 44 + i * 2);
165
+
166
+ writeFileSync(wavPath, wav);
167
+
168
+ // ── Output metadata ───────────────────────────────────────────────────
169
+
170
+ // Find the dominant BPM — the tempo that lasts the longest across the piece.
171
+ // This handles songs with intros, outros, or gradual tempo shifts better than
172
+ // just using the first tempo event.
173
+ const totalTicks = Math.max(...midi.tracks.map((tr) => {
174
+ let tk = 0; for (const ev of tr) tk += ev.deltaTime; return tk;
175
+ }));
176
+ let dominantUspb = tempoMap[0].uspb;
177
+ let longestDuration = 0;
178
+ for (let i = 0; i < tempoMap.length; i++) {
179
+ const end = tempoMap[i + 1]?.tick ?? totalTicks;
180
+ const dur = end - tempoMap[i].tick;
181
+ if (dur > longestDuration) { longestDuration = dur; dominantUspb = tempoMap[i].uspb; }
182
+ }
183
+ const bpm = Math.round(60_000_000 / dominantUspb);
184
+ process.stdout.write(JSON.stringify({ bpm, durationMs: Math.round(totalMs) }) + '\n');
@@ -0,0 +1,24 @@
1
+ /**
2
+ * MidiTrackBrowser — Searchable MIDI track selector for the Snake game.
3
+ *
4
+ * Shows a filtered list of tracks from freemidi-catalog.ts.
5
+ * Type to filter, arrow keys to navigate, Enter to select.
6
+ * Esc cancels (calls onCancel).
7
+ *
8
+ * Attribution: All tracks from https://freemidi.org
9
+ */
10
+ import { type MidiTrack } from './freemidi-catalog.js';
11
+ export interface SelectedTrack {
12
+ title: string;
13
+ artist: string;
14
+ url: string;
15
+ downloadPage?: string;
16
+ }
17
+ interface MidiTrackBrowserProps {
18
+ onSelect: (track: SelectedTrack) => void;
19
+ onCancel: () => void;
20
+ /** Track list to browse. Defaults to the built-in MIDI_CATALOG. */
21
+ tracks?: MidiTrack[];
22
+ }
23
+ export declare const MidiTrackBrowser: ({ onSelect, onCancel, tracks }: MidiTrackBrowserProps) => import("react/jsx-runtime").JSX.Element;
24
+ export {};
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * MidiTrackBrowser — Searchable MIDI track selector for the Snake game.
4
+ *
5
+ * Shows a filtered list of tracks from freemidi-catalog.ts.
6
+ * Type to filter, arrow keys to navigate, Enter to select.
7
+ * Esc cancels (calls onCancel).
8
+ *
9
+ * Attribution: All tracks from https://freemidi.org
10
+ */
11
+ import { Box, Text, useInput } from 'ink';
12
+ import { useState, useMemo, useRef } from 'react';
13
+ import { Colors } from './styles.js';
14
+ import { MIDI_CATALOG, FALLBACK_TRACK, DEFAULT_TRACK_ID, freemidiUrls, } from './freemidi-catalog.js';
15
+ const VISIBLE_ROWS = 10;
16
+ function trackToSelected(track) {
17
+ const urls = freemidiUrls(track);
18
+ return {
19
+ title: track.title,
20
+ artist: track.artist,
21
+ url: urls.getter,
22
+ downloadPage: urls.downloadPage,
23
+ };
24
+ }
25
+ function fallbackSelected() {
26
+ return {
27
+ title: FALLBACK_TRACK.title,
28
+ artist: FALLBACK_TRACK.artist,
29
+ url: FALLBACK_TRACK.directUrl,
30
+ };
31
+ }
32
+ export const MidiTrackBrowser = ({ onSelect, onCancel, tracks }) => {
33
+ const catalog = tracks ?? MIDI_CATALOG;
34
+ const [query, setQuery] = useState('');
35
+ const scrollRef = useRef(0);
36
+ const sorted = useMemo(() => [...catalog].sort((a, b) => a.title.localeCompare(b.title)), [catalog]);
37
+ const [focused, setFocused] = useState(() => {
38
+ const defaultIdx = sorted.findIndex((t) => t.id === DEFAULT_TRACK_ID);
39
+ return defaultIdx >= 0 ? defaultIdx : 0;
40
+ });
41
+ const filtered = useMemo(() => {
42
+ if (!query)
43
+ return sorted;
44
+ const q = query.toLowerCase();
45
+ return sorted.filter((t) => t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q));
46
+ }, [query]);
47
+ // Clamp focused index to filtered list length
48
+ const safeFocused = Math.min(focused, Math.max(0, filtered.length - 1));
49
+ // Scrolling
50
+ if (safeFocused < scrollRef.current) {
51
+ scrollRef.current = safeFocused;
52
+ }
53
+ else if (safeFocused >= scrollRef.current + VISIBLE_ROWS) {
54
+ scrollRef.current = safeFocused - VISIBLE_ROWS + 1;
55
+ }
56
+ const scrollOffset = scrollRef.current;
57
+ const visible = filtered.slice(scrollOffset, scrollOffset + VISIBLE_ROWS);
58
+ const hasAbove = scrollOffset > 0;
59
+ const hasBelow = scrollOffset + VISIBLE_ROWS < filtered.length;
60
+ useInput((input, key) => {
61
+ if (key.escape) {
62
+ onCancel();
63
+ return;
64
+ }
65
+ if (key.return) {
66
+ const track = filtered[safeFocused];
67
+ if (track) {
68
+ onSelect(trackToSelected(track));
69
+ }
70
+ else {
71
+ // If nothing matches, use fallback
72
+ onSelect(fallbackSelected());
73
+ }
74
+ return;
75
+ }
76
+ if (key.upArrow) {
77
+ setFocused((f) => Math.max(0, Math.min(f, filtered.length - 1) - 1));
78
+ return;
79
+ }
80
+ if (key.downArrow) {
81
+ setFocused((f) => Math.min(filtered.length - 1, Math.min(f, filtered.length - 1) + 1));
82
+ return;
83
+ }
84
+ // Text input for search
85
+ if (key.backspace || key.delete) {
86
+ setQuery((q) => q.slice(0, -1));
87
+ setFocused(0);
88
+ scrollRef.current = 0;
89
+ return;
90
+ }
91
+ // Printable characters
92
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
93
+ setQuery((q) => q + input);
94
+ setFocused(0);
95
+ scrollRef.current = 0;
96
+ }
97
+ });
98
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { gap: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: Colors.accent, children: "Select Track" }), _jsx(Text, { dimColor: true, children: "attributed to freemidi.org" })] }), _jsxs(Box, { gap: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Search:" }), _jsx(Text, { color: Colors.accent, children: query || ' ' }), _jsx(Text, { dimColor: true, children: "\u258C" })] }), filtered.length === 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "No matches." }), _jsx(Text, { dimColor: true, children: "Press Esc to cancel or Backspace to clear." })] })) : (_jsxs(Box, { flexDirection: "column", children: [hasAbove && (_jsxs(Text, { dimColor: true, children: [" \u2191 ", scrollOffset, " more"] })), visible.map((track, i) => {
99
+ const idx = scrollOffset + i;
100
+ const isFocused = idx === safeFocused;
101
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isFocused ? Colors.accent : undefined, dimColor: !isFocused, children: isFocused ? '▶' : ' ' }), _jsx(Text, { color: isFocused ? Colors.accent : undefined, bold: isFocused, dimColor: !isFocused, children: track.title }), _jsx(Text, { dimColor: true, children: "\u2014" }), _jsx(Text, { dimColor: true, children: track.artist })] }, track.id));
102
+ }), hasBelow && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", filtered.length - scrollOffset - VISIBLE_ROWS, " more"] }))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate" }), _jsx(Text, { dimColor: true, children: "Enter select" }), _jsx(Text, { dimColor: true, children: "Esc cancel" })] })] }));
103
+ };
104
+ //# sourceMappingURL=MidiTrackBrowser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MidiTrackBrowser.js","sourceRoot":"","sources":["../../src/MidiTrackBrowser.tsx"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EACL,YAAY,EACZ,cAAc,EACd,gBAAgB,EAEhB,YAAY,GACb,MAAM,uBAAuB,CAAC;AAE/B,MAAM,YAAY,GAAG,EAAE,CAAC;AASxB,SAAS,eAAe,CAAC,KAAgB;IACvC,MAAM,IAAI,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IACjC,OAAO;QACL,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,GAAG,EAAE,IAAI,CAAC,MAAM;QAChB,YAAY,EAAE,IAAI,CAAC,YAAY;KAChC,CAAC;AACJ,CAAC;AAED,SAAS,gBAAgB;IACvB,OAAO;QACL,KAAK,EAAE,cAAc,CAAC,KAAK;QAC3B,MAAM,EAAE,cAAc,CAAC,MAAM;QAC7B,GAAG,EAAE,cAAc,CAAC,SAAS;KAC9B,CAAC;AACJ,CAAC;AASD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAyB,EAAE,EAAE;IACxF,MAAM,OAAO,GAAG,MAAM,IAAI,YAAY,CAAC;IACvC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC;IACvC,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,OAAO,CACpB,GAAG,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EACjE,CAAC,OAAO,CAAC,CACV,CAAC;IAEF,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE;QAC1C,MAAM,UAAU,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,gBAAgB,CAAC,CAAC;QACtE,OAAO,UAAU,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE;QAC5B,IAAI,CAAC,KAAK;YAAE,OAAO,MAAM,CAAC;QAC1B,MAAM,CAAC,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAC9B,OAAO,MAAM,CAAC,MAAM,CAClB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC/E,CAAC;IACJ,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAEZ,8CAA8C;IAC9C,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;IAExE,YAAY;IACZ,IAAI,WAAW,GAAG,SAAS,CAAC,OAAO,EAAE,CAAC;QACpC,SAAS,CAAC,OAAO,GAAG,WAAW,CAAC;IAClC,CAAC;SAAM,IAAI,WAAW,IAAI,SAAS,CAAC,OAAO,GAAG,YAAY,EAAE,CAAC;QAC3D,SAAS,CAAC,OAAO,GAAG,WAAW,GAAG,YAAY,GAAG,CAAC,CAAC;IACrD,CAAC;IACD,MAAM,YAAY,GAAG,SAAS,CAAC,OAAO,CAAC;IAEvC,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,YAAY,EAAE,YAAY,GAAG,YAAY,CAAC,CAAC;IAC1E,MAAM,QAAQ,GAAG,YAAY,GAAG,CAAC,CAAC;IAClC,MAAM,QAAQ,GAAG,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC,MAAM,CAAC;IAE/D,QAAQ,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;QACtB,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YACf,QAAQ,EAAE,CAAC;YACX,OAAO;QACT,CAAC;QAED,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YACf,MAAM,KAAK,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;YACpC,IAAI,KAAK,EAAE,CAAC;gBACV,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC;YACnC,CAAC;iBAAM,CAAC;gBACN,mCAAmC;gBACnC,QAAQ,CAAC,gBAAgB,EAAE,CAAC,CAAC;YAC/B,CAAC;YACD,OAAO;QACT,CAAC;QAED,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;YAChB,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACrE,OAAO;QACT,CAAC;QACD,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;YAClB,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACvF,OAAO;QACT,CAAC;QAED,wBAAwB;QACxB,IAAI,GAAG,CAAC,SAAS,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YAChC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAChC,UAAU,CAAC,CAAC,CAAC,CAAC;YACd,SAAS,CAAC,OAAO,GAAG,CAAC,CAAC;YACtB,OAAO;QACT,CAAC;QAED,uBAAuB;QACvB,IAAI,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC1D,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC;YAC3B,UAAU,CAAC,CAAC,CAAC,CAAC;YACd,SAAS,CAAC,OAAO,GAAG,CAAC,CAAC;QACxB,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,CACL,MAAC,GAAG,IAAC,aAAa,EAAC,QAAQ,EAAC,QAAQ,EAAE,CAAC,aACrC,MAAC,GAAG,IAAC,GAAG,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,aAC1B,KAAC,IAAI,IAAC,IAAI,QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,6BAAqB,EACpD,KAAC,IAAI,IAAC,QAAQ,iDAAkC,IAC5C,EAGN,MAAC,GAAG,IAAC,GAAG,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,aAC1B,KAAC,IAAI,IAAC,QAAQ,8BAAe,EAC7B,KAAC,IAAI,IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,YAAG,KAAK,IAAI,GAAG,GAAQ,EACjD,KAAC,IAAI,IAAC,QAAQ,6BAAS,IACnB,EAGL,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CACvB,MAAC,GAAG,IAAC,GAAG,EAAE,CAAC,aACT,KAAC,IAAI,IAAC,QAAQ,kCAAmB,EACjC,KAAC,IAAI,IAAC,QAAQ,iEAAkD,IAC5D,CACP,CAAC,CAAC,CAAC,CACF,MAAC,GAAG,IAAC,aAAa,EAAC,QAAQ,aACxB,QAAQ,IAAI,CACX,MAAC,IAAI,IAAC,QAAQ,gCAAM,YAAY,aAAa,CAC9C,EACA,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;wBACxB,MAAM,GAAG,GAAG,YAAY,GAAG,CAAC,CAAC;wBAC7B,MAAM,SAAS,GAAG,GAAG,KAAK,WAAW,CAAC;wBACtC,OAAO,CACL,MAAC,GAAG,IAAgB,GAAG,EAAE,CAAC,aACxB,KAAC,IAAI,IAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC,SAAS,YACrE,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GACjB,EACP,KAAC,IAAI,IACH,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,EAC5C,IAAI,EAAE,SAAS,EACf,QAAQ,EAAE,CAAC,SAAS,YAEnB,KAAK,CAAC,KAAK,GACP,EACP,KAAC,IAAI,IAAC,QAAQ,6BAAS,EACvB,KAAC,IAAI,IAAC,QAAQ,kBAAE,KAAK,CAAC,MAAM,GAAQ,KAZ5B,KAAK,CAAC,EAAE,CAaZ,CACP,CAAC;oBACJ,CAAC,CAAC,EACD,QAAQ,IAAI,CACX,MAAC,IAAI,IAAC,QAAQ,gCAAM,QAAQ,CAAC,MAAM,GAAG,YAAY,GAAG,YAAY,aAAa,CAC/E,IACG,CACP,EAED,MAAC,GAAG,IAAC,SAAS,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,aACvB,KAAC,IAAI,IAAC,QAAQ,4CAAmB,EACjC,KAAC,IAAI,IAAC,QAAQ,mCAAoB,EAClC,KAAC,IAAI,IAAC,QAAQ,iCAAkB,IAC5B,IACF,CACP,CAAC;AACJ,CAAC,CAAC","sourcesContent":["/**\n * MidiTrackBrowser — Searchable MIDI track selector for the Snake game.\n *\n * Shows a filtered list of tracks from freemidi-catalog.ts.\n * Type to filter, arrow keys to navigate, Enter to select.\n * Esc cancels (calls onCancel).\n *\n * Attribution: All tracks from https://freemidi.org\n */\n\nimport { Box, Text, useInput } from 'ink';\nimport { useState, useMemo, useRef } from 'react';\nimport { Colors } from './styles.js';\nimport {\n MIDI_CATALOG,\n FALLBACK_TRACK,\n DEFAULT_TRACK_ID,\n type MidiTrack,\n freemidiUrls,\n} from './freemidi-catalog.js';\n\nconst VISIBLE_ROWS = 10;\n\nexport interface SelectedTrack {\n title: string;\n artist: string;\n url: string; // direct URL (fallback) or getter URL (freemidi)\n downloadPage?: string; // freemidi step-1 page (cookie grab)\n}\n\nfunction trackToSelected(track: MidiTrack): SelectedTrack {\n const urls = freemidiUrls(track);\n return {\n title: track.title,\n artist: track.artist,\n url: urls.getter,\n downloadPage: urls.downloadPage,\n };\n}\n\nfunction fallbackSelected(): SelectedTrack {\n return {\n title: FALLBACK_TRACK.title,\n artist: FALLBACK_TRACK.artist,\n url: FALLBACK_TRACK.directUrl,\n };\n}\n\ninterface MidiTrackBrowserProps {\n onSelect: (track: SelectedTrack) => void;\n onCancel: () => void;\n /** Track list to browse. Defaults to the built-in MIDI_CATALOG. */\n tracks?: MidiTrack[];\n}\n\nexport const MidiTrackBrowser = ({ onSelect, onCancel, tracks }: MidiTrackBrowserProps) => {\n const catalog = tracks ?? MIDI_CATALOG;\n const [query, setQuery] = useState('');\n const scrollRef = useRef(0);\n const sorted = useMemo(\n () => [...catalog].sort((a, b) => a.title.localeCompare(b.title)),\n [catalog],\n );\n\n const [focused, setFocused] = useState(() => {\n const defaultIdx = sorted.findIndex((t) => t.id === DEFAULT_TRACK_ID);\n return defaultIdx >= 0 ? defaultIdx : 0;\n });\n\n const filtered = useMemo(() => {\n if (!query) return sorted;\n const q = query.toLowerCase();\n return sorted.filter(\n (t) => t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q),\n );\n }, [query]);\n\n // Clamp focused index to filtered list length\n const safeFocused = Math.min(focused, Math.max(0, filtered.length - 1));\n\n // Scrolling\n if (safeFocused < scrollRef.current) {\n scrollRef.current = safeFocused;\n } else if (safeFocused >= scrollRef.current + VISIBLE_ROWS) {\n scrollRef.current = safeFocused - VISIBLE_ROWS + 1;\n }\n const scrollOffset = scrollRef.current;\n\n const visible = filtered.slice(scrollOffset, scrollOffset + VISIBLE_ROWS);\n const hasAbove = scrollOffset > 0;\n const hasBelow = scrollOffset + VISIBLE_ROWS < filtered.length;\n\n useInput((input, key) => {\n if (key.escape) {\n onCancel();\n return;\n }\n\n if (key.return) {\n const track = filtered[safeFocused];\n if (track) {\n onSelect(trackToSelected(track));\n } else {\n // If nothing matches, use fallback\n onSelect(fallbackSelected());\n }\n return;\n }\n\n if (key.upArrow) {\n setFocused((f) => Math.max(0, Math.min(f, filtered.length - 1) - 1));\n return;\n }\n if (key.downArrow) {\n setFocused((f) => Math.min(filtered.length - 1, Math.min(f, filtered.length - 1) + 1));\n return;\n }\n\n // Text input for search\n if (key.backspace || key.delete) {\n setQuery((q) => q.slice(0, -1));\n setFocused(0);\n scrollRef.current = 0;\n return;\n }\n\n // Printable characters\n if (input && !key.ctrl && !key.meta && input.length === 1) {\n setQuery((q) => q + input);\n setFocused(0);\n scrollRef.current = 0;\n }\n });\n\n return (\n <Box flexDirection=\"column\" paddingX={1}>\n <Box gap={2} marginBottom={1}>\n <Text bold color={Colors.accent}>Select Track</Text>\n <Text dimColor>attributed to freemidi.org</Text>\n </Box>\n\n {/* Search box */}\n <Box gap={1} marginBottom={1}>\n <Text dimColor>Search:</Text>\n <Text color={Colors.accent}>{query || ' '}</Text>\n <Text dimColor>▌</Text>\n </Box>\n\n {/* Results */}\n {filtered.length === 0 ? (\n <Box gap={1}>\n <Text dimColor>No matches.</Text>\n <Text dimColor>Press Esc to cancel or Backspace to clear.</Text>\n </Box>\n ) : (\n <Box flexDirection=\"column\">\n {hasAbove && (\n <Text dimColor> ↑ {scrollOffset} more</Text>\n )}\n {visible.map((track, i) => {\n const idx = scrollOffset + i;\n const isFocused = idx === safeFocused;\n return (\n <Box key={track.id} gap={1}>\n <Text color={isFocused ? Colors.accent : undefined} dimColor={!isFocused}>\n {isFocused ? '▶' : ' '}\n </Text>\n <Text\n color={isFocused ? Colors.accent : undefined}\n bold={isFocused}\n dimColor={!isFocused}\n >\n {track.title}\n </Text>\n <Text dimColor>—</Text>\n <Text dimColor>{track.artist}</Text>\n </Box>\n );\n })}\n {hasBelow && (\n <Text dimColor> ↓ {filtered.length - scrollOffset - VISIBLE_ROWS} more</Text>\n )}\n </Box>\n )}\n\n <Box marginTop={1} gap={2}>\n <Text dimColor>↑↓ navigate</Text>\n <Text dimColor>Enter select</Text>\n <Text dimColor>Esc cancel</Text>\n </Box>\n </Box>\n );\n};\n"]}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * MusicSettings — In-game music configuration panel for Snake.
3
+ *
4
+ * Two sections (Tab to switch):
5
+ * Volumes — BGM / Tink / SFX, adjust with ← →
6
+ * Tracks — searchable list from freemidi.org
7
+ *
8
+ * Esc / Enter on a track closes and applies changes.
9
+ */
10
+ import { type MidiTrack } from './freemidi-catalog.js';
11
+ export type { MidiTrack };
12
+ export interface SelectedTrack {
13
+ title: string;
14
+ artist: string;
15
+ url: string;
16
+ downloadPage?: string;
17
+ }
18
+ export interface MusicConfig {
19
+ track: SelectedTrack;
20
+ musicVolume: number;
21
+ tinkVolume: number;
22
+ sfxVolume: number;
23
+ }
24
+ interface MusicSettingsProps {
25
+ initial: MusicConfig;
26
+ onApply: (config: MusicConfig) => void;
27
+ onCancel: () => void;
28
+ accentColor?: string;
29
+ /** Track list to show in the browser. Defaults to the built-in MIDI_CATALOG. */
30
+ tracks?: MidiTrack[];
31
+ }
32
+ export declare const MusicSettings: ({ initial, onApply, onCancel, accentColor, tracks }: MusicSettingsProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,144 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * MusicSettings — In-game music configuration panel for Snake.
4
+ *
5
+ * Two sections (Tab to switch):
6
+ * Volumes — BGM / Tink / SFX, adjust with ← →
7
+ * Tracks — searchable list from freemidi.org
8
+ *
9
+ * Esc / Enter on a track closes and applies changes.
10
+ */
11
+ import { Box, Text, useInput } from 'ink';
12
+ import { useState, useMemo, useRef } from 'react';
13
+ import { DEFAULT_COLORS } from './types.js';
14
+ import { MIDI_CATALOG, FALLBACK_TRACK, DEFAULT_TRACK_ID, freemidiUrls, } from './freemidi-catalog.js';
15
+ function trackToSelected(track) {
16
+ const urls = freemidiUrls(track);
17
+ return { title: track.title, artist: track.artist, url: urls.getter, downloadPage: urls.downloadPage };
18
+ }
19
+ function fallbackSelected() {
20
+ return { title: FALLBACK_TRACK.title, artist: FALLBACK_TRACK.artist, url: FALLBACK_TRACK.directUrl };
21
+ }
22
+ const VISIBLE_ROWS = 8;
23
+ const VOLUME_STEP = 0.1;
24
+ function volBar(v) {
25
+ const filled = Math.round(v * 10);
26
+ return '█'.repeat(filled) + '░'.repeat(10 - filled);
27
+ }
28
+ function fmtPct(v) {
29
+ return `${Math.round(v * 100)}%`;
30
+ }
31
+ function snap(v) {
32
+ return Math.max(0, Math.min(1, Math.round(v * 10) / 10));
33
+ }
34
+ const VOICES = [
35
+ { key: 'musicVolume', label: 'Music (BGM)' },
36
+ { key: 'tinkVolume', label: 'Tink (per frame)' },
37
+ { key: 'sfxVolume', label: 'SFX (eat / die)' },
38
+ ];
39
+ export const MusicSettings = ({ initial, onApply, onCancel, accentColor, tracks }) => {
40
+ const accent = accentColor ?? DEFAULT_COLORS.accent;
41
+ const catalog = tracks ?? MIDI_CATALOG;
42
+ const [section, setSection] = useState('tracks');
43
+ const [voiceFocus, setVoiceFocus] = useState(0);
44
+ const [volumes, setVolumes] = useState({
45
+ musicVolume: initial.musicVolume,
46
+ tinkVolume: initial.tinkVolume,
47
+ sfxVolume: initial.sfxVolume,
48
+ });
49
+ const [selectedTrack, setSelectedTrack] = useState(initial.track);
50
+ // Track browser state
51
+ const [query, setQuery] = useState('');
52
+ const scrollRef = useRef(0);
53
+ const sorted = useMemo(() => [...catalog].sort((a, b) => a.title.localeCompare(b.title)), [catalog]);
54
+ const [trackFocus, setTrackFocus] = useState(() => {
55
+ const idx = sorted.findIndex((t) => t.id === DEFAULT_TRACK_ID);
56
+ return idx >= 0 ? idx : 0;
57
+ });
58
+ const filtered = useMemo(() => {
59
+ if (!query)
60
+ return sorted;
61
+ const q = query.toLowerCase();
62
+ return sorted.filter((t) => t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q));
63
+ }, [query, sorted]);
64
+ const safeFocus = Math.min(trackFocus, Math.max(0, filtered.length - 1));
65
+ if (safeFocus < scrollRef.current)
66
+ scrollRef.current = safeFocus;
67
+ else if (safeFocus >= scrollRef.current + VISIBLE_ROWS)
68
+ scrollRef.current = safeFocus - VISIBLE_ROWS + 1;
69
+ const scrollOffset = scrollRef.current;
70
+ const visible = filtered.slice(scrollOffset, scrollOffset + VISIBLE_ROWS);
71
+ const apply = (track) => {
72
+ onApply({ track, ...volumes });
73
+ };
74
+ useInput((input, key) => {
75
+ if (key.escape) {
76
+ onCancel();
77
+ return;
78
+ }
79
+ if (key.tab) {
80
+ setSection((s) => s === 'tracks' ? 'volumes' : 'tracks');
81
+ return;
82
+ }
83
+ if (section === 'volumes') {
84
+ if (key.upArrow) {
85
+ setVoiceFocus((f) => Math.max(0, f - 1));
86
+ return;
87
+ }
88
+ if (key.downArrow) {
89
+ setVoiceFocus((f) => Math.min(VOICES.length - 1, f + 1));
90
+ return;
91
+ }
92
+ if (key.leftArrow || key.rightArrow) {
93
+ const voice = VOICES[voiceFocus];
94
+ if (!voice)
95
+ return;
96
+ const delta = key.leftArrow ? -VOLUME_STEP : VOLUME_STEP;
97
+ setVolumes((v) => ({ ...v, [voice.key]: snap(v[voice.key] + delta) }));
98
+ return;
99
+ }
100
+ if (key.return) {
101
+ apply(selectedTrack);
102
+ return;
103
+ }
104
+ }
105
+ if (section === 'tracks') {
106
+ if (key.return) {
107
+ const track = filtered[safeFocus];
108
+ const sel = track ? trackToSelected(track) : fallbackSelected();
109
+ setSelectedTrack(sel);
110
+ apply(sel);
111
+ return;
112
+ }
113
+ if (key.upArrow) {
114
+ setTrackFocus((f) => Math.max(0, Math.min(f, filtered.length - 1) - 1));
115
+ return;
116
+ }
117
+ if (key.downArrow) {
118
+ setTrackFocus((f) => Math.min(filtered.length - 1, Math.min(f, filtered.length - 1) + 1));
119
+ return;
120
+ }
121
+ if (key.backspace || key.delete) {
122
+ setQuery((q) => q.slice(0, -1));
123
+ setTrackFocus(0);
124
+ scrollRef.current = 0;
125
+ return;
126
+ }
127
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
128
+ setQuery((q) => q + input);
129
+ setTrackFocus(0);
130
+ scrollRef.current = 0;
131
+ }
132
+ }
133
+ });
134
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { gap: 3, marginBottom: 1, children: [_jsx(Text, { bold: true, color: accent, children: "Music Settings" }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: section === 'tracks', color: section === 'tracks' ? accent : undefined, dimColor: section !== 'tracks', children: "Tracks" }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: section === 'volumes', color: section === 'volumes' ? accent : undefined, dimColor: section !== 'volumes', children: "Volumes" })] }), _jsx(Text, { dimColor: true, children: "Tab to switch" })] }), section === 'volumes' && (_jsxs(Box, { flexDirection: "column", gap: 0, children: [VOICES.map((voice, i) => {
135
+ const isFocused = i === voiceFocus;
136
+ const val = volumes[voice.key];
137
+ return (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: isFocused ? accent : undefined, dimColor: !isFocused, children: isFocused ? '▶' : ' ' }), _jsx(Box, { width: 18, children: _jsx(Text, { color: isFocused ? accent : undefined, dimColor: !isFocused, children: voice.label }) }), _jsx(Text, { color: isFocused ? accent : undefined, dimColor: !isFocused, children: volBar(val) }), _jsx(Text, { dimColor: true, children: fmtPct(val) })] }, voice.key));
138
+ }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u2191\u2193 select voice" }), _jsx(Text, { dimColor: true, children: "\u2190 \u2192 adjust" }), _jsx(Text, { dimColor: true, children: "Enter apply" }), _jsx(Text, { dimColor: true, children: "Esc cancel" })] })] })), section === 'tracks' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Now playing:" }), _jsx(Text, { color: accent, children: selectedTrack.title }), _jsxs(Text, { dimColor: true, children: ["\u2014 ", selectedTrack.artist] })] }), _jsxs(Box, { gap: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Search:" }), _jsx(Text, { color: accent, children: query || ' ' }), _jsx(Text, { dimColor: true, children: "\u258C" })] }), filtered.length === 0 ? (_jsx(Text, { dimColor: true, children: "No matches. Backspace to clear." })) : (_jsxs(Box, { flexDirection: "column", children: [scrollOffset > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", scrollOffset, " more"] }), visible.map((track, i) => {
139
+ const idx = scrollOffset + i;
140
+ const isFocused = idx === safeFocus;
141
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isFocused ? accent : undefined, dimColor: !isFocused, children: isFocused ? '▶' : ' ' }), _jsx(Text, { color: isFocused ? accent : undefined, bold: isFocused, dimColor: !isFocused, children: track.title }), _jsxs(Text, { dimColor: true, children: ["\u2014 ", track.artist] })] }, track.id));
142
+ }), scrollOffset + VISIBLE_ROWS < filtered.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", filtered.length - scrollOffset - VISIBLE_ROWS, " more"] }))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate" }), _jsx(Text, { dimColor: true, children: "Enter select" }), _jsx(Text, { dimColor: true, children: "Esc cancel" })] })] }))] }));
143
+ };
144
+ //# sourceMappingURL=MusicSettings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MusicSettings.js","sourceRoot":"","sources":["../../src/MusicSettings.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * MusicSettings — In-game music configuration panel for Snake.\n *\n * Two sections (Tab to switch):\n * Volumes — BGM / Tink / SFX, adjust with ← →\n * Tracks — searchable list from freemidi.org\n *\n * Esc / Enter on a track closes and applies changes.\n */\n\nimport { Box, Text, useInput } from 'ink';\nimport { useState, useMemo, useRef } from 'react';\nimport { DEFAULT_COLORS } from './types.js';\nimport {\n MIDI_CATALOG,\n FALLBACK_TRACK,\n DEFAULT_TRACK_ID,\n type MidiTrack,\n freemidiUrls,\n} from './freemidi-catalog.js';\n\nexport type { MidiTrack };\n\nexport interface SelectedTrack {\n title: string;\n artist: string;\n url: string;\n downloadPage?: string;\n}\n\nexport interface MusicConfig {\n track: SelectedTrack;\n musicVolume: number;\n tinkVolume: number;\n sfxVolume: number;\n}\n\nfunction trackToSelected(track: MidiTrack): SelectedTrack {\n const urls = freemidiUrls(track);\n return { title: track.title, artist: track.artist, url: urls.getter, downloadPage: urls.downloadPage };\n}\n\nfunction fallbackSelected(): SelectedTrack {\n return { title: FALLBACK_TRACK.title, artist: FALLBACK_TRACK.artist, url: FALLBACK_TRACK.directUrl };\n}\n\nconst VISIBLE_ROWS = 8;\nconst VOLUME_STEP = 0.1;\n\nfunction volBar(v: number): string {\n const filled = Math.round(v * 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n}\n\nfunction fmtPct(v: number): string {\n return `${Math.round(v * 100)}%`;\n}\n\nfunction snap(v: number): number {\n return Math.max(0, Math.min(1, Math.round(v * 10) / 10));\n}\n\ntype Section = 'volumes' | 'tracks';\n\nconst VOICES = [\n { key: 'musicVolume' as const, label: 'Music (BGM)' },\n { key: 'tinkVolume' as const, label: 'Tink (per frame)' },\n { key: 'sfxVolume' as const, label: 'SFX (eat / die)' },\n];\n\ninterface MusicSettingsProps {\n initial: MusicConfig;\n onApply: (config: MusicConfig) => void;\n onCancel: () => void;\n accentColor?: string;\n /** Track list to show in the browser. Defaults to the built-in MIDI_CATALOG. */\n tracks?: MidiTrack[];\n}\n\nexport const MusicSettings = ({ initial, onApply, onCancel, accentColor, tracks }: MusicSettingsProps) => {\n const accent = accentColor ?? DEFAULT_COLORS.accent;\n const catalog = tracks ?? MIDI_CATALOG;\n const [section, setSection] = useState<Section>('tracks');\n const [voiceFocus, setVoiceFocus] = useState(0);\n const [volumes, setVolumes] = useState({\n musicVolume: initial.musicVolume,\n tinkVolume: initial.tinkVolume,\n sfxVolume: initial.sfxVolume,\n });\n const [selectedTrack, setSelectedTrack] = useState<SelectedTrack>(initial.track);\n\n // Track browser state\n const [query, setQuery] = useState('');\n const scrollRef = useRef(0);\n const sorted = useMemo(\n () => [...catalog].sort((a, b) => a.title.localeCompare(b.title)),\n [catalog],\n );\n const [trackFocus, setTrackFocus] = useState(() => {\n const idx = sorted.findIndex((t) => t.id === DEFAULT_TRACK_ID);\n return idx >= 0 ? idx : 0;\n });\n const filtered = useMemo(() => {\n if (!query) return sorted;\n const q = query.toLowerCase();\n return sorted.filter((t) => t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q));\n }, [query, sorted]);\n\n const safeFocus = Math.min(trackFocus, Math.max(0, filtered.length - 1));\n if (safeFocus < scrollRef.current) scrollRef.current = safeFocus;\n else if (safeFocus >= scrollRef.current + VISIBLE_ROWS) scrollRef.current = safeFocus - VISIBLE_ROWS + 1;\n const scrollOffset = scrollRef.current;\n const visible = filtered.slice(scrollOffset, scrollOffset + VISIBLE_ROWS);\n\n const apply = (track: SelectedTrack) => {\n onApply({ track, ...volumes });\n };\n\n useInput((input, key) => {\n if (key.escape) { onCancel(); return; }\n\n if (key.tab) {\n setSection((s) => s === 'tracks' ? 'volumes' : 'tracks');\n return;\n }\n\n if (section === 'volumes') {\n if (key.upArrow) { setVoiceFocus((f) => Math.max(0, f - 1)); return; }\n if (key.downArrow) { setVoiceFocus((f) => Math.min(VOICES.length - 1, f + 1)); return; }\n if (key.leftArrow || key.rightArrow) {\n const voice = VOICES[voiceFocus];\n if (!voice) return;\n const delta = key.leftArrow ? -VOLUME_STEP : VOLUME_STEP;\n setVolumes((v) => ({ ...v, [voice.key]: snap(v[voice.key] + delta) }));\n return;\n }\n if (key.return) { apply(selectedTrack); return; }\n }\n\n if (section === 'tracks') {\n if (key.return) {\n const track = filtered[safeFocus];\n const sel = track ? trackToSelected(track) : fallbackSelected();\n setSelectedTrack(sel);\n apply(sel);\n return;\n }\n if (key.upArrow) { setTrackFocus((f) => Math.max(0, Math.min(f, filtered.length - 1) - 1)); return; }\n if (key.downArrow) { setTrackFocus((f) => Math.min(filtered.length - 1, Math.min(f, filtered.length - 1) + 1)); return; }\n\n if (key.backspace || key.delete) {\n setQuery((q) => q.slice(0, -1));\n setTrackFocus(0); scrollRef.current = 0;\n return;\n }\n if (input && !key.ctrl && !key.meta && input.length === 1) {\n setQuery((q) => q + input);\n setTrackFocus(0); scrollRef.current = 0;\n }\n }\n });\n\n return (\n <Box flexDirection=\"column\" paddingX={1}>\n {/* Header */}\n <Box gap={3} marginBottom={1}>\n <Text bold color={accent}>Music Settings</Text>\n <Box gap={1}>\n <Text\n bold={section === 'tracks'}\n color={section === 'tracks' ? accent : undefined}\n dimColor={section !== 'tracks'}\n >\n Tracks\n </Text>\n <Text dimColor>·</Text>\n <Text\n bold={section === 'volumes'}\n color={section === 'volumes' ? accent : undefined}\n dimColor={section !== 'volumes'}\n >\n Volumes\n </Text>\n </Box>\n <Text dimColor>Tab to switch</Text>\n </Box>\n\n {section === 'volumes' && (\n <Box flexDirection=\"column\" gap={0}>\n {VOICES.map((voice, i) => {\n const isFocused = i === voiceFocus;\n const val = volumes[voice.key];\n return (\n <Box key={voice.key} gap={2}>\n <Text color={isFocused ? accent : undefined} dimColor={!isFocused}>\n {isFocused ? '▶' : ' '}\n </Text>\n <Box width={18}>\n <Text color={isFocused ? accent : undefined} dimColor={!isFocused}>\n {voice.label}\n </Text>\n </Box>\n <Text color={isFocused ? accent : undefined} dimColor={!isFocused}>\n {volBar(val)}\n </Text>\n <Text dimColor>{fmtPct(val)}</Text>\n </Box>\n );\n })}\n <Box marginTop={1} gap={2}>\n <Text dimColor>↑↓ select voice</Text>\n <Text dimColor>← → adjust</Text>\n <Text dimColor>Enter apply</Text>\n <Text dimColor>Esc cancel</Text>\n </Box>\n </Box>\n )}\n\n {section === 'tracks' && (\n <Box flexDirection=\"column\">\n <Box gap={1} marginBottom={1}>\n <Text dimColor>Now playing:</Text>\n <Text color={accent}>{selectedTrack.title}</Text>\n <Text dimColor>— {selectedTrack.artist}</Text>\n </Box>\n <Box gap={1} marginBottom={1}>\n <Text dimColor>Search:</Text>\n <Text color={accent}>{query || ' '}</Text>\n <Text dimColor>▌</Text>\n </Box>\n {filtered.length === 0 ? (\n <Text dimColor>No matches. Backspace to clear.</Text>\n ) : (\n <Box flexDirection=\"column\">\n {scrollOffset > 0 && <Text dimColor> ↑ {scrollOffset} more</Text>}\n {visible.map((track, i) => {\n const idx = scrollOffset + i;\n const isFocused = idx === safeFocus;\n return (\n <Box key={track.id} gap={1}>\n <Text color={isFocused ? accent : undefined} dimColor={!isFocused}>\n {isFocused ? '▶' : ' '}\n </Text>\n <Text color={isFocused ? accent : undefined} bold={isFocused} dimColor={!isFocused}>\n {track.title}\n </Text>\n <Text dimColor>— {track.artist}</Text>\n </Box>\n );\n })}\n {scrollOffset + VISIBLE_ROWS < filtered.length && (\n <Text dimColor> ↓ {filtered.length - scrollOffset - VISIBLE_ROWS} more</Text>\n )}\n </Box>\n )}\n <Box marginTop={1} gap={2}>\n <Text dimColor>↑↓ navigate</Text>\n <Text dimColor>Enter select</Text>\n <Text dimColor>Esc cancel</Text>\n </Box>\n </Box>\n )}\n </Box>\n );\n};\n"]}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SnakeGame — Playable Snake in the terminal.
3
+ *
4
+ * Controls: WASD to move · Space to pause · R to restart · M to change music
5
+ *
6
+ * Uses a ref-backed game state with a single persistent interval so
7
+ * the game loop never captures stale closure values.
8
+ */
9
+ import { type MidiTrack } from './freemidi-catalog.js';
10
+ import { type SnakeColors } from './types.js';
11
+ export type { SnakeColors };
12
+ interface SnakeGameProps {
13
+ onExit?: () => void;
14
+ /** Enable or disable all audio (default: true) */
15
+ music?: boolean;
16
+ /** Override any of the game's colors */
17
+ colors?: SnakeColors;
18
+ /**
19
+ * Directory for cached audio files (synthesized WAVs and per-note tinks).
20
+ * Defaults to the OS temp directory.
21
+ */
22
+ cacheDir?: string;
23
+ /**
24
+ * Path to the JSON file used to persist settings (high score, volumes).
25
+ * Defaults to ~/.snake-game.json
26
+ */
27
+ settingsFile?: string;
28
+ /** Grid width in cells (default: 20) */
29
+ width?: number;
30
+ /** Grid height in cells (default: 10) */
31
+ height?: number;
32
+ /**
33
+ * Track list shown in the music browser.
34
+ * Defaults to the built-in MIDI_CATALOG from freemidi.org.
35
+ */
36
+ tracks?: MidiTrack[];
37
+ }
38
+ export declare const SnakeGame: ({ onExit, music, colors, cacheDir, settingsFile, width, height, tracks, }?: SnakeGameProps) => import("react/jsx-runtime").JSX.Element;