@onnie81/murdoku-spor 1.1.0 → 1.2.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/README.md +11 -2
- package/client/src/art/objects.jsx +152 -91
- package/client/src/audio.js +266 -0
- package/client/src/components/Board.jsx +81 -15
- package/client/src/components/CluePanel.jsx +2 -2
- package/client/src/components/PlayView.jsx +193 -74
- package/client/src/components/SettingsView.jsx +18 -0
- package/client/src/components/Tray.jsx +6 -5
- package/client/src/game.js +17 -1
- package/client/src/i18n/en.js +10 -2
- package/client/src/i18n/es.js +14 -2
- package/client/src/i18n/index.js +21 -0
- package/client/src/objectModel.js +25 -0
- package/client/src/store.js +8 -1
- package/client/src/styles.css +89 -1
- package/murdoku.bundle +0 -0
- package/package.json +1 -1
- package/server/api.js +1 -1
- package/server/public/assets/index-CWjzD9nk.js +40 -0
- package/server/public/assets/index-DUtSdtnb.css +1 -0
- package/server/public/index.html +2 -2
- package/shared/clues.js +103 -37
- package/shared/generator.js +84 -16
- package/shared/model.js +60 -37
- package/server/public/assets/index-COhad2G1.js +0 -40
- package/server/public/assets/index-h4AwMTqN.css +0 -1
package/README.md
CHANGED
|
@@ -30,8 +30,17 @@ victim** is the killer.
|
|
|
30
30
|
long-press to mark X, keyboard shortcuts (1-9, N, X, U, R, E).
|
|
31
31
|
- **Multilingual** — English and Spanish shipped; clues are structured data
|
|
32
32
|
rendered through per-language templates, so adding a language is one file.
|
|
33
|
-
- **
|
|
34
|
-
|
|
33
|
+
- **Furniture that matters** — blockers, seats you can be *seated on*
|
|
34
|
+
(armchairs, two-square sofas), beds you can be *lying in*, and wall-mounted
|
|
35
|
+
windows/paintings/mirrors that live on the walls themselves (windows count
|
|
36
|
+
from both sides). Clues use all of it: "was seated on the sofa", "was not
|
|
37
|
+
lying in any bed", "was beside a window".
|
|
38
|
+
- **Original art & sound** — 12 hand-drawn SVG suspect portraits, colorful
|
|
39
|
+
furniture sprites, 8 scene themes; synthesized noir soundtrack and sound
|
|
40
|
+
effects (WebAudio, zero assets, all original).
|
|
41
|
+
- **Phone-first play screen** — fullscreen mode, an always-visible lateral
|
|
42
|
+
suspect overlay that turns translucent while you place, floating controls,
|
|
43
|
+
and a clue bottom-sheet.
|
|
35
44
|
|
|
36
45
|
## Quick start (Docker)
|
|
37
46
|
|
|
@@ -1,158 +1,219 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Colorful original furniture art.
|
|
2
|
+
// FloorIcon — single-cell pieces, viewBox 0 0 100 100
|
|
3
|
+
// WideIcon — sofa & bed spanning two cells, viewBox 0 0 200 100
|
|
4
|
+
// WallIcon — wall-mounted pieces (window/painting/mirror), viewBox 0 0 100 36
|
|
2
5
|
import React from 'react';
|
|
3
6
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
7
|
+
const INK = '#241e33';
|
|
8
|
+
const O = { stroke: INK, strokeWidth: 2.5, strokeLinejoin: 'round', strokeLinecap: 'round' };
|
|
9
|
+
const WOOD = '#8a5a33', WOOD_D = '#6d4426', BRASS = '#c9a227', CREAM = '#f4efe2';
|
|
6
10
|
|
|
7
|
-
const
|
|
11
|
+
const FLOOR = {
|
|
8
12
|
piano: (
|
|
9
13
|
<>
|
|
10
|
-
<path {...
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<path {...O} d="M20,28 L70,28 Q86,32 86,52 Q86,72 62,72 L20,72 Z" fill="#2b2436" />
|
|
15
|
+
<path d="M24,32 L66,32 Q80,35 80,50 Q80,66 60,66 L24,66 Z" fill="#3a3350" opacity="0.8" />
|
|
16
|
+
<rect {...O} x="14" y="60" width="46" height="14" rx="3" fill={CREAM} />
|
|
17
|
+
{[20, 27, 34, 41, 48, 55].map(x => <rect key={x} x={x} y="60" width="3" height="8" fill={INK} />)}
|
|
18
|
+
<rect x="20" y="74" width="6" height="12" fill={WOOD_D} />
|
|
19
|
+
<rect x="52" y="74" width="6" height="12" fill={WOOD_D} />
|
|
20
|
+
<circle cx="74" cy="30" r="3" fill={BRASS} />
|
|
14
21
|
</>
|
|
15
22
|
),
|
|
16
23
|
armchair: (
|
|
17
24
|
<>
|
|
18
|
-
<path {...
|
|
19
|
-
<rect {...
|
|
20
|
-
<rect {...
|
|
21
|
-
<rect {...
|
|
25
|
+
<path {...O} d="M24,30 Q50,18 76,30 L76,60 L24,60 Z" fill="#2e7d6e" />
|
|
26
|
+
<rect {...O} x="28" y="52" width="44" height="16" rx="6" fill="#3f9683" />
|
|
27
|
+
<rect {...O} x="14" y="42" width="14" height="30" rx="7" fill="#256a5d" />
|
|
28
|
+
<rect {...O} x="72" y="42" width="14" height="30" rx="7" fill="#256a5d" />
|
|
29
|
+
<rect x="30" y="72" width="7" height="12" fill={WOOD} />
|
|
30
|
+
<rect x="62" y="72" width="7" height="12" fill={WOOD} />
|
|
22
31
|
</>
|
|
23
32
|
),
|
|
24
33
|
bookshelf: (
|
|
25
34
|
<>
|
|
26
|
-
<rect {...
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
{[
|
|
30
|
-
<rect key={i}
|
|
35
|
+
<rect {...O} x="22" y="14" width="56" height="72" rx="3" fill={WOOD} />
|
|
36
|
+
<rect x="27" y="20" width="46" height="26" fill="#513a20" />
|
|
37
|
+
<rect x="27" y="52" width="46" height="26" fill="#513a20" />
|
|
38
|
+
{[['#e5484d', 29], ['#0090ff', 37], ['#ffc53d', 45], ['#46a758', 52], ['#6e56cf', 60]].map(([f, x], i) => (
|
|
39
|
+
<rect key={i} x={x} y={i % 2 ? 26 : 24} width="7" height={i % 2 ? 20 : 22} fill={f} stroke={INK} strokeWidth="1.5" />
|
|
31
40
|
))}
|
|
32
|
-
{[
|
|
33
|
-
<rect key={i}
|
|
41
|
+
{[['#12a594', 30], ['#e93d82', 38], ['#c9a227', 46], ['#ad7f58', 54], ['#0090ff', 62]].map(([f, x], i) => (
|
|
42
|
+
<rect key={i} x={x} y={i % 2 ? 58 : 56} width="7" height={i % 2 ? 20 : 22} fill={f} stroke={INK} strokeWidth="1.5" />
|
|
34
43
|
))}
|
|
35
44
|
</>
|
|
36
45
|
),
|
|
37
46
|
plant: (
|
|
38
47
|
<>
|
|
39
|
-
<path {...
|
|
40
|
-
<path {...
|
|
41
|
-
<path {...
|
|
42
|
-
<path {...
|
|
43
|
-
<
|
|
48
|
+
<path {...O} d="M50,52 Q30,48 26,26 Q46,30 50,52 Z" fill="#46a758" />
|
|
49
|
+
<path {...O} d="M50,52 Q70,48 74,26 Q54,30 50,52 Z" fill="#5a9e66" />
|
|
50
|
+
<path {...O} d="M50,50 Q48,26 50,14 Q58,28 50,50 Z" fill="#3f8d50" />
|
|
51
|
+
<path {...O} d="M34,58 L66,58 L60,84 L40,84 Z" fill="#b5623a" />
|
|
52
|
+
<rect {...O} x="31" y="54" width="38" height="8" rx="3" fill="#c9744a" />
|
|
44
53
|
</>
|
|
45
54
|
),
|
|
46
55
|
clock: (
|
|
47
56
|
<>
|
|
48
|
-
<rect {...
|
|
49
|
-
<
|
|
50
|
-
<
|
|
51
|
-
<
|
|
52
|
-
<
|
|
57
|
+
<rect {...O} x="34" y="12" width="32" height="76" rx="5" fill={WOOD_D} />
|
|
58
|
+
<rect x="38" y="46" width="24" height="34" rx="3" fill="#513a20" />
|
|
59
|
+
<circle {...O} cx="50" cy="29" r="12" fill={CREAM} />
|
|
60
|
+
<path d="M50,29 L50,21 M50,29 L56,32" stroke={INK} strokeWidth="2.5" strokeLinecap="round" />
|
|
61
|
+
<line x1="50" y1="48" x2="50" y2="70" stroke={BRASS} strokeWidth="3" />
|
|
62
|
+
<circle cx="50" cy="72" r="6" fill={BRASS} stroke={INK} strokeWidth="2" />
|
|
53
63
|
</>
|
|
54
64
|
),
|
|
55
65
|
chest: (
|
|
56
66
|
<>
|
|
57
|
-
<path {...
|
|
58
|
-
<rect {...
|
|
59
|
-
<rect
|
|
60
|
-
<
|
|
61
|
-
<
|
|
67
|
+
<path {...O} d="M20,44 Q20,26 50,26 Q80,26 80,44 L80,52 L20,52 Z" fill="#9a6537" />
|
|
68
|
+
<rect {...O} x="20" y="52" width="60" height="26" rx="3" fill={WOOD} />
|
|
69
|
+
<rect x="30" y="28" width="6" height="49" fill={BRASS} stroke={INK} strokeWidth="1.5" />
|
|
70
|
+
<rect x="64" y="28" width="6" height="49" fill={BRASS} stroke={INK} strokeWidth="1.5" />
|
|
71
|
+
<rect {...O} x="44" y="46" width="12" height="14" rx="2" fill={BRASS} />
|
|
72
|
+
<circle cx="50" cy="53" r="2" fill={INK} />
|
|
62
73
|
</>
|
|
63
74
|
),
|
|
64
75
|
stove: (
|
|
65
76
|
<>
|
|
66
|
-
<rect {...
|
|
67
|
-
<rect {...
|
|
68
|
-
<
|
|
69
|
-
<circle
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<>
|
|
75
|
-
<path {...S} d="M20,32 L20,76 M80,44 L80,76" />
|
|
76
|
-
<path {...F} d="M20,52 L80,52 L80,66 L20,66 Z" opacity="0.7" />
|
|
77
|
-
<rect {...F} x="24" y="44" width="18" height="9" rx="4" opacity="0.9" />
|
|
78
|
-
<path {...F} d="M20,32 Q26,26 32,32 L32,44 L20,44 Z" opacity="0.5" />
|
|
77
|
+
<rect {...O} x="24" y="36" width="52" height="44" rx="5" fill="#3a3644" />
|
|
78
|
+
<rect {...O} x="32" y="46" width="24" height="22" rx="3" fill="#f76b15" />
|
|
79
|
+
<path d="M36,64 Q40,52 44,64 Q48,52 52,64 Z" fill="#ffc53d" />
|
|
80
|
+
<circle cx="66" cy="46" r="3.5" fill={BRASS} />
|
|
81
|
+
<circle cx="66" cy="56" r="3.5" fill={BRASS} />
|
|
82
|
+
<path {...O} d="M62,36 L62,20 Q62,14 70,14" fill="none" strokeWidth="5" stroke="#3a3644" />
|
|
83
|
+
<rect x="28" y="80" width="8" height="7" fill={INK} />
|
|
84
|
+
<rect x="64" y="80" width="8" height="7" fill={INK} />
|
|
79
85
|
</>
|
|
80
86
|
),
|
|
81
87
|
desk: (
|
|
82
88
|
<>
|
|
83
|
-
<rect {...
|
|
84
|
-
<rect {...
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
<rect
|
|
89
|
+
<rect {...O} x="16" y="34" width="68" height="9" rx="2" fill={WOOD} />
|
|
90
|
+
<rect {...O} x="22" y="43" width="26" height="24" fill={WOOD_D} />
|
|
91
|
+
<rect x="26" y="47" width="18" height="7" fill={WOOD} stroke={INK} strokeWidth="1.5" />
|
|
92
|
+
<circle cx="35" cy="50.5" r="1.8" fill={BRASS} />
|
|
93
|
+
<rect x="26" y="57" width="18" height="7" fill={WOOD} stroke={INK} strokeWidth="1.5" />
|
|
94
|
+
<circle cx="35" cy="60.5" r="1.8" fill={BRASS} />
|
|
95
|
+
<path d="M76,43 L76,78 M22,67 L22,78" stroke={INK} strokeWidth="4" strokeLinecap="round" />
|
|
96
|
+
<rect x="50" y="27" width="22" height="8" rx="1" fill={CREAM} stroke={INK} strokeWidth="1.5" transform="rotate(-5 61 31)" />
|
|
97
|
+
<rect x="52" y="35" width="26" height="4" fill="#3f5c57" />
|
|
88
98
|
</>
|
|
89
99
|
),
|
|
90
100
|
statue: (
|
|
91
101
|
<>
|
|
92
|
-
<rect {...
|
|
93
|
-
<rect {...
|
|
94
|
-
<path {...
|
|
102
|
+
<rect {...O} x="30" y="72" width="40" height="12" fill="#7d7691" />
|
|
103
|
+
<rect {...O} x="36" y="62" width="28" height="10" fill="#9a93b0" />
|
|
104
|
+
<path {...O} d="M44,62 Q40,48 46,40 Q42,34 47,28 Q44,20 50,18 Q56,20 53,28 Q58,34 54,40 Q60,48 56,62 Z" fill="#b8b2c4" />
|
|
105
|
+
<circle cx="50" cy="22" r="1.5" fill="#8d87a0" />
|
|
95
106
|
</>
|
|
96
107
|
),
|
|
97
108
|
telescope: (
|
|
98
109
|
<>
|
|
99
|
-
<path {...
|
|
100
|
-
<path {...
|
|
101
|
-
<
|
|
102
|
-
<
|
|
110
|
+
<path {...O} d="M28,58 L62,28 L72,40 L38,68 Z" fill={BRASS} />
|
|
111
|
+
<path {...O} d="M60,22 L78,36 L73,42 L56,28 Z" fill="#a3821a" />
|
|
112
|
+
<rect x="40" y="48" width="14" height="6" fill="#a3821a" transform="rotate(-41 47 51)" />
|
|
113
|
+
<path d="M46,62 L34,86 M46,62 L60,86" stroke={INK} strokeWidth="5" strokeLinecap="round" />
|
|
114
|
+
<circle {...O} cx="47" cy="60" r="7" fill="#3a3644" />
|
|
115
|
+
<circle cx="76" cy="33" r="2.5" fill="#e6f4ff" />
|
|
103
116
|
</>
|
|
104
117
|
),
|
|
105
118
|
lamp: (
|
|
106
119
|
<>
|
|
107
|
-
<
|
|
108
|
-
<
|
|
109
|
-
<
|
|
120
|
+
<ellipse cx="50" cy="34" rx="26" ry="10" fill="#ffd76a" opacity="0.35" />
|
|
121
|
+
<path {...O} d="M32,16 L68,16 L59,40 L41,40 Z" fill="#ffc53d" />
|
|
122
|
+
<line x1="50" y1="40" x2="50" y2="78" stroke={BRASS} strokeWidth="4" />
|
|
123
|
+
<path {...O} d="M34,84 Q50,76 66,84 Z" fill={WOOD_D} />
|
|
110
124
|
</>
|
|
111
125
|
),
|
|
112
|
-
|
|
126
|
+
crate: (
|
|
113
127
|
<>
|
|
114
|
-
<
|
|
115
|
-
<
|
|
116
|
-
<
|
|
117
|
-
<
|
|
128
|
+
<rect {...O} x="22" y="28" width="56" height="50" fill="#c9a06a" />
|
|
129
|
+
<line x1="22" y1="45" x2="78" y2="45" stroke={WOOD_D} strokeWidth="3" />
|
|
130
|
+
<line x1="22" y1="62" x2="78" y2="62" stroke={WOOD_D} strokeWidth="3" />
|
|
131
|
+
<rect x="38" y="32" width="24" height="9" rx="1.5" fill={CREAM} stroke={INK} strokeWidth="1.5" />
|
|
132
|
+
{[27, 73].map(x => [52, 69].map(y => <circle key={x + ':' + y} cx={x} cy={y} r="1.8" fill={WOOD_D} />))}
|
|
118
133
|
</>
|
|
119
134
|
),
|
|
120
|
-
|
|
135
|
+
harp: (
|
|
121
136
|
<>
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
<path {...O} d="M30,82 L30,30 Q30,12 50,14 Q72,16 74,44 L74,82 Z" fill="none" stroke={BRASS} strokeWidth="7" />
|
|
138
|
+
<path {...O} d="M24,80 L80,80 L76,88 L28,88 Z" fill={BRASS} />
|
|
139
|
+
{[40, 48, 56, 64].map((x, i) => (
|
|
140
|
+
<line key={x} x1={x} y1={24 + i * 4} x2={x} y2="80" stroke={CREAM} strokeWidth="2" />
|
|
141
|
+
))}
|
|
142
|
+
</>
|
|
143
|
+
)
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const WIDE = {
|
|
147
|
+
sofa: (
|
|
148
|
+
<>
|
|
149
|
+
<path {...O} d="M28,26 Q100,12 172,26 L172,56 L28,56 Z" fill="#a04848" />
|
|
150
|
+
<rect {...O} x="34" y="46" width="64" height="20" rx="7" fill="#b85c5c" />
|
|
151
|
+
<rect {...O} x="102" y="46" width="64" height="20" rx="7" fill="#b85c5c" />
|
|
152
|
+
<rect {...O} x="14" y="34" width="18" height="36" rx="9" fill="#8d3d3d" />
|
|
153
|
+
<rect {...O} x="168" y="34" width="18" height="36" rx="9" fill="#8d3d3d" />
|
|
154
|
+
<rect x="36" y="70" width="8" height="12" fill={WOOD_D} />
|
|
155
|
+
<rect x="156" y="70" width="8" height="12" fill={WOOD_D} />
|
|
156
|
+
<path d="M60,32 Q66,38 60,44 M136,32 Q142,38 136,44" stroke="#7d3535" strokeWidth="2.5" fill="none" />
|
|
126
157
|
</>
|
|
127
158
|
),
|
|
128
|
-
|
|
159
|
+
bed: (
|
|
129
160
|
<>
|
|
130
|
-
<rect {...
|
|
131
|
-
<
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
<
|
|
161
|
+
<rect {...O} x="14" y="18" width="16" height="58" rx="4" fill={WOOD} />
|
|
162
|
+
<rect {...O} x="176" y="30" width="12" height="46" rx="3" fill={WOOD} />
|
|
163
|
+
<rect {...O} x="28" y="40" width="150" height="30" rx="6" fill={CREAM} />
|
|
164
|
+
<path {...O} d="M74,40 L178,40 L178,70 L74,70 Q66,55 74,40 Z" fill="#41609e" />
|
|
165
|
+
<path d="M84,48 L170,48 M80,56 L170,56" stroke="#33518b" strokeWidth="2.5" />
|
|
166
|
+
<rect {...O} x="36" y="44" width="30" height="16" rx="7" fill="#ffffff" />
|
|
167
|
+
<rect x="32" y="70" width="8" height="10" fill={WOOD_D} />
|
|
168
|
+
<rect x="168" y="70" width="8" height="10" fill={WOOD_D} />
|
|
169
|
+
</>
|
|
170
|
+
)
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const WALL = {
|
|
174
|
+
window: (
|
|
175
|
+
<>
|
|
176
|
+
<rect x="6" y="2" width="88" height="32" rx="4" fill={CREAM} stroke={INK} strokeWidth="3" />
|
|
177
|
+
<rect x="12" y="7" width="36" height="22" fill="#2b3a5c" stroke={INK} strokeWidth="2" />
|
|
178
|
+
<rect x="52" y="7" width="36" height="22" fill="#33456b" stroke={INK} strokeWidth="2" />
|
|
179
|
+
<circle cx="72" cy="13" r="4" fill="#f4efe2" opacity="0.9" />
|
|
180
|
+
<circle cx="22" cy="12" r="1.3" fill="#cfe0ff" />
|
|
181
|
+
<circle cx="36" cy="22" r="1" fill="#cfe0ff" />
|
|
182
|
+
<path d="M52,26 L64,10" stroke="#8fa8d8" strokeWidth="1.5" opacity="0.7" />
|
|
138
183
|
</>
|
|
139
184
|
),
|
|
140
|
-
|
|
185
|
+
painting: (
|
|
141
186
|
<>
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
187
|
+
<rect x="8" y="2" width="84" height="32" rx="3" fill={BRASS} stroke={INK} strokeWidth="3" />
|
|
188
|
+
<rect x="15" y="8" width="70" height="20" fill="#8fb8d8" />
|
|
189
|
+
<path d="M15,24 L34,13 L46,22 L60,10 L85,24 L85,28 L15,28 Z" fill="#46a758" stroke={INK} strokeWidth="1" />
|
|
190
|
+
<circle cx="70" cy="13" r="3" fill="#ffd76a" />
|
|
191
|
+
</>
|
|
192
|
+
),
|
|
193
|
+
mirror: (
|
|
194
|
+
<>
|
|
195
|
+
<ellipse cx="50" cy="18" rx="42" ry="16" fill={BRASS} stroke={INK} strokeWidth="3" />
|
|
196
|
+
<ellipse cx="50" cy="18" rx="34" ry="11" fill="#cfd8e8" stroke={INK} strokeWidth="1.5" />
|
|
197
|
+
<path d="M30,22 L44,11 M40,24 L52,14" stroke="#f2f6ff" strokeWidth="2.5" opacity="0.8" />
|
|
147
198
|
</>
|
|
148
199
|
)
|
|
149
200
|
};
|
|
150
201
|
|
|
151
|
-
export function
|
|
202
|
+
export function FloorIcon({ type, size = 40, className = '' }) {
|
|
152
203
|
return (
|
|
153
|
-
<svg viewBox="0 0 100 100" width={size} height={size} className={`obj-icon ${className}`}
|
|
154
|
-
|
|
155
|
-
{ICONS[type] || <circle {...S} cx="50" cy="50" r="26" />}
|
|
204
|
+
<svg viewBox="0 0 100 100" width={size} height={size} className={`obj-icon ${className}`} role="img" aria-label={type}>
|
|
205
|
+
{FLOOR[type] || FLOOR.crate}
|
|
156
206
|
</svg>
|
|
157
207
|
);
|
|
158
208
|
}
|
|
209
|
+
|
|
210
|
+
export const hasWide = (type) => !!WIDE[type];
|
|
211
|
+
export const hasWall = (type) => !!WALL[type];
|
|
212
|
+
export const wideArt = (type) => WIDE[type] || WIDE.sofa;
|
|
213
|
+
export const wallArt = (type) => WALL[type] || WALL.window;
|
|
214
|
+
export const floorArt = (type) => FLOOR[type] || FLOOR.crate;
|
|
215
|
+
|
|
216
|
+
// kept for any legacy callers
|
|
217
|
+
export function ObjectIcon(props) {
|
|
218
|
+
return <FloorIcon {...props} />;
|
|
219
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Original audio: synthesized SFX + a generative noir music loop.
|
|
2
|
+
// Everything is produced with WebAudio oscillators/noise — no assets, no
|
|
3
|
+
// copyright entanglements. Autoplay-safe: the context starts on first gesture.
|
|
4
|
+
|
|
5
|
+
let ctx = null;
|
|
6
|
+
let master, sfxBus, musicBus;
|
|
7
|
+
let enabled = { sfx: true, music: true };
|
|
8
|
+
let musicOn = false;
|
|
9
|
+
let schedTimer = null;
|
|
10
|
+
|
|
11
|
+
function ensureCtx() {
|
|
12
|
+
if (ctx) return ctx;
|
|
13
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
14
|
+
if (!AC) return null;
|
|
15
|
+
ctx = new AC();
|
|
16
|
+
master = ctx.createGain();
|
|
17
|
+
master.gain.value = 0.9;
|
|
18
|
+
master.connect(ctx.destination);
|
|
19
|
+
sfxBus = ctx.createGain();
|
|
20
|
+
sfxBus.gain.value = 0.5;
|
|
21
|
+
sfxBus.connect(master);
|
|
22
|
+
musicBus = ctx.createGain();
|
|
23
|
+
musicBus.gain.value = 0.0;
|
|
24
|
+
// gentle echo on the music bus
|
|
25
|
+
const delay = ctx.createDelay(1.0);
|
|
26
|
+
delay.delayTime.value = 0.31;
|
|
27
|
+
const fb = ctx.createGain();
|
|
28
|
+
fb.gain.value = 0.25;
|
|
29
|
+
const wet = ctx.createGain();
|
|
30
|
+
wet.gain.value = 0.22;
|
|
31
|
+
musicBus.connect(master);
|
|
32
|
+
musicBus.connect(delay);
|
|
33
|
+
delay.connect(fb);
|
|
34
|
+
fb.connect(delay);
|
|
35
|
+
delay.connect(wet);
|
|
36
|
+
wet.connect(master);
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function initAudioOnGesture() {
|
|
41
|
+
const kick = () => {
|
|
42
|
+
const c = ensureCtx();
|
|
43
|
+
if (!c) return;
|
|
44
|
+
const go = () => { if (enabled.music) startMusic(); };
|
|
45
|
+
if (c.state === 'suspended') c.resume().then(go).catch(() => {});
|
|
46
|
+
else go();
|
|
47
|
+
window.removeEventListener('pointerdown', kick);
|
|
48
|
+
window.removeEventListener('keydown', kick);
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener('pointerdown', kick);
|
|
51
|
+
window.addEventListener('keydown', kick);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setAudioPrefs({ sfx, music }) {
|
|
55
|
+
if (typeof sfx === 'boolean') enabled.sfx = sfx;
|
|
56
|
+
if (typeof music === 'boolean') {
|
|
57
|
+
enabled.music = music;
|
|
58
|
+
if (ctx) {
|
|
59
|
+
if (music) startMusic();
|
|
60
|
+
else stopMusic();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function env(node, t0, a, peak, d, sustain = 0.0001) {
|
|
66
|
+
node.gain.cancelScheduledValues(t0);
|
|
67
|
+
node.gain.setValueAtTime(0.0001, t0);
|
|
68
|
+
node.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), t0 + a);
|
|
69
|
+
node.gain.exponentialRampToValueAtTime(sustain, t0 + a + d);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tone(freq, { type = 'sine', a = 0.005, d = 0.2, peak = 0.5, bus = null, glide = null, at = 0 } = {}) {
|
|
73
|
+
const c = ensureCtx();
|
|
74
|
+
if (!c) return;
|
|
75
|
+
const t0 = c.currentTime + at;
|
|
76
|
+
const o = c.createOscillator();
|
|
77
|
+
const g = c.createGain();
|
|
78
|
+
o.type = type;
|
|
79
|
+
o.frequency.setValueAtTime(freq, t0);
|
|
80
|
+
if (glide) o.frequency.exponentialRampToValueAtTime(glide, t0 + a + d);
|
|
81
|
+
env(g, t0, a, peak, d);
|
|
82
|
+
o.connect(g);
|
|
83
|
+
g.connect(bus || sfxBus);
|
|
84
|
+
o.start(t0);
|
|
85
|
+
o.stop(t0 + a + d + 0.1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function noise({ a = 0.001, d = 0.08, peak = 0.3, freq = 2200, q = 1, at = 0, type = 'bandpass' } = {}) {
|
|
89
|
+
const c = ensureCtx();
|
|
90
|
+
if (!c) return;
|
|
91
|
+
const t0 = c.currentTime + at;
|
|
92
|
+
const len = Math.ceil(c.sampleRate * (a + d + 0.05));
|
|
93
|
+
const buf = c.createBuffer(1, len, c.sampleRate);
|
|
94
|
+
const data = buf.getChannelData(0);
|
|
95
|
+
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
|
96
|
+
const src = c.createBufferSource();
|
|
97
|
+
src.buffer = buf;
|
|
98
|
+
const f = c.createBiquadFilter();
|
|
99
|
+
f.type = type;
|
|
100
|
+
f.frequency.value = freq;
|
|
101
|
+
f.Q.value = q;
|
|
102
|
+
const g = c.createGain();
|
|
103
|
+
env(g, t0, a, peak, d);
|
|
104
|
+
src.connect(f);
|
|
105
|
+
f.connect(g);
|
|
106
|
+
g.connect(sfxBus);
|
|
107
|
+
src.start(t0);
|
|
108
|
+
src.stop(t0 + a + d + 0.05);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function sfx(name) {
|
|
112
|
+
if (!enabled.sfx) return;
|
|
113
|
+
const c = ensureCtx();
|
|
114
|
+
if (!c) return;
|
|
115
|
+
if (c.state === 'suspended') c.resume();
|
|
116
|
+
switch (name) {
|
|
117
|
+
case 'place': // soft wooden tap + warm blip
|
|
118
|
+
noise({ d: 0.05, peak: 0.25, freq: 900, q: 0.8, type: 'lowpass' });
|
|
119
|
+
tone(392, { type: 'triangle', d: 0.14, peak: 0.22 });
|
|
120
|
+
tone(587.3, { type: 'sine', d: 0.18, peak: 0.12, at: 0.02 });
|
|
121
|
+
break;
|
|
122
|
+
case 'pickup':
|
|
123
|
+
tone(523, { type: 'triangle', d: 0.09, peak: 0.15, glide: 740 });
|
|
124
|
+
break;
|
|
125
|
+
case 'thud': // illegal square
|
|
126
|
+
tone(110, { type: 'sine', d: 0.16, peak: 0.3, glide: 70 });
|
|
127
|
+
noise({ d: 0.06, peak: 0.12, freq: 300, type: 'lowpass' });
|
|
128
|
+
break;
|
|
129
|
+
case 'x':
|
|
130
|
+
noise({ d: 0.07, peak: 0.2, freq: 1800, q: 2 });
|
|
131
|
+
noise({ d: 0.07, peak: 0.16, freq: 1400, q: 2, at: 0.07 });
|
|
132
|
+
break;
|
|
133
|
+
case 'tick': // pencil mark
|
|
134
|
+
noise({ d: 0.03, peak: 0.18, freq: 3200, q: 3 });
|
|
135
|
+
break;
|
|
136
|
+
case 'paper': // clue strike
|
|
137
|
+
noise({ d: 0.12, peak: 0.16, freq: 2400, q: 0.7 });
|
|
138
|
+
break;
|
|
139
|
+
case 'click':
|
|
140
|
+
tone(880, { type: 'square', d: 0.03, peak: 0.05 });
|
|
141
|
+
break;
|
|
142
|
+
case 'chime': // hint / board complete
|
|
143
|
+
tone(659.3, { type: 'sine', d: 0.3, peak: 0.18 });
|
|
144
|
+
tone(987.8, { type: 'sine', d: 0.4, peak: 0.14, at: 0.09 });
|
|
145
|
+
break;
|
|
146
|
+
case 'wrong':
|
|
147
|
+
tone(196, { type: 'sawtooth', d: 0.25, peak: 0.14, glide: 155 });
|
|
148
|
+
tone(185, { type: 'sawtooth', d: 0.3, peak: 0.12, at: 0.05, glide: 147 });
|
|
149
|
+
break;
|
|
150
|
+
case 'reveal': // accusation moment
|
|
151
|
+
[220, 261.6, 311.1].forEach((f, i) => tone(f, { type: 'triangle', d: 0.5, peak: 0.12, at: i * 0.05 }));
|
|
152
|
+
break;
|
|
153
|
+
case 'win': { // noir sting: Dm(add9) arpeggio up + sparkle
|
|
154
|
+
[146.8, 220, 293.7, 349.2, 440, 587.3].forEach((f, i) =>
|
|
155
|
+
tone(f, { type: 'triangle', d: 0.55, peak: 0.16, at: i * 0.09 }));
|
|
156
|
+
tone(1174.7, { type: 'sine', d: 0.8, peak: 0.1, at: 0.6 });
|
|
157
|
+
noise({ d: 0.5, peak: 0.05, freq: 6000, q: 0.5, at: 0.55, type: 'highpass' });
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------- generative noir loop ----------
|
|
166
|
+
// D natural-minor palette. A slow bass walk, sparse vibraphone phrases,
|
|
167
|
+
// an occasional brushed-cymbal breath. Nothing repeats exactly.
|
|
168
|
+
|
|
169
|
+
const BASS = [73.42, 87.31, 98.0, 110.0, 65.41, 87.31]; // D2 F2 G2 A2 C2 F2
|
|
170
|
+
const VIBES = [293.66, 349.23, 392.0, 440.0, 523.25, 587.33, 698.46]; // D4 F4 G4 A4 C5 D5 F5
|
|
171
|
+
let step = 0;
|
|
172
|
+
|
|
173
|
+
function playBass(t, f) {
|
|
174
|
+
const c = ctx;
|
|
175
|
+
const o = c.createOscillator();
|
|
176
|
+
const g = c.createGain();
|
|
177
|
+
const flt = c.createBiquadFilter();
|
|
178
|
+
o.type = 'triangle';
|
|
179
|
+
o.frequency.value = f;
|
|
180
|
+
flt.type = 'lowpass';
|
|
181
|
+
flt.frequency.value = 260;
|
|
182
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
183
|
+
g.gain.exponentialRampToValueAtTime(0.34, t + 0.04);
|
|
184
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.55);
|
|
185
|
+
o.connect(flt); flt.connect(g); g.connect(musicBus);
|
|
186
|
+
o.start(t); o.stop(t + 1.7);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function playVibe(t, f, vel = 0.12) {
|
|
190
|
+
const c = ctx;
|
|
191
|
+
const o = c.createOscillator();
|
|
192
|
+
const o2 = c.createOscillator();
|
|
193
|
+
const g = c.createGain();
|
|
194
|
+
o.type = 'sine'; o.frequency.value = f;
|
|
195
|
+
o2.type = 'sine'; o2.frequency.value = f * 2.01;
|
|
196
|
+
const g2 = c.createGain(); g2.gain.value = 0.25;
|
|
197
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
198
|
+
g.gain.exponentialRampToValueAtTime(vel, t + 0.015);
|
|
199
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.9);
|
|
200
|
+
o.connect(g); o2.connect(g2); g2.connect(g); g.connect(musicBus);
|
|
201
|
+
o.start(t); o.stop(t + 2); o2.start(t); o2.stop(t + 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function playBrush(t) {
|
|
205
|
+
const c = ctx;
|
|
206
|
+
const len = Math.ceil(c.sampleRate * 0.5);
|
|
207
|
+
const buf = c.createBuffer(1, len, c.sampleRate);
|
|
208
|
+
const d = buf.getChannelData(0);
|
|
209
|
+
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
|
|
210
|
+
const src = c.createBufferSource();
|
|
211
|
+
src.buffer = buf;
|
|
212
|
+
const f = c.createBiquadFilter();
|
|
213
|
+
f.type = 'highpass'; f.frequency.value = 5000;
|
|
214
|
+
const g = c.createGain();
|
|
215
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
216
|
+
g.gain.exponentialRampToValueAtTime(0.045, t + 0.05);
|
|
217
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.45);
|
|
218
|
+
src.connect(f); f.connect(g); g.connect(musicBus);
|
|
219
|
+
src.start(t); src.stop(t + 0.5);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let nextBeat = 0;
|
|
223
|
+
const BEAT = 0.86; // ~70bpm
|
|
224
|
+
|
|
225
|
+
function scheduler() {
|
|
226
|
+
const c = ctx;
|
|
227
|
+
if (!c || !musicOn) return;
|
|
228
|
+
const horizon = c.currentTime + 1.6;
|
|
229
|
+
while (nextBeat < horizon) {
|
|
230
|
+
const t = nextBeat;
|
|
231
|
+
const bar = Math.floor(step / 4) % BASS.length;
|
|
232
|
+
if (step % 2 === 0) playBass(t, BASS[bar]);
|
|
233
|
+
if (Math.random() < 0.55) {
|
|
234
|
+
const n = VIBES[Math.floor(Math.random() * VIBES.length)];
|
|
235
|
+
playVibe(t + (Math.random() < 0.4 ? BEAT / 2 : 0) + Math.random() * 0.03, n, 0.07 + Math.random() * 0.07);
|
|
236
|
+
if (Math.random() < 0.25) playVibe(t + BEAT * 0.75, n * 1.335, 0.05); // fourth up
|
|
237
|
+
}
|
|
238
|
+
if (step % 8 === 6 && Math.random() < 0.7) playBrush(t + BEAT / 2);
|
|
239
|
+
step++;
|
|
240
|
+
nextBeat += BEAT;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function startMusic() {
|
|
245
|
+
const c = ensureCtx();
|
|
246
|
+
if (!c) return;
|
|
247
|
+
musicOn = true;
|
|
248
|
+
if (c.state === 'suspended') return; // the gesture handler calls again after resume
|
|
249
|
+
nextBeat = c.currentTime + 0.1;
|
|
250
|
+
step = 0;
|
|
251
|
+
musicBus.gain.cancelScheduledValues(c.currentTime);
|
|
252
|
+
musicBus.gain.setValueAtTime(0.0001, c.currentTime);
|
|
253
|
+
musicBus.gain.exponentialRampToValueAtTime(0.5, c.currentTime + 2.5);
|
|
254
|
+
clearInterval(schedTimer);
|
|
255
|
+
schedTimer = setInterval(scheduler, 400);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function stopMusic() {
|
|
259
|
+
musicOn = false;
|
|
260
|
+
clearInterval(schedTimer);
|
|
261
|
+
if (ctx && musicBus) {
|
|
262
|
+
musicBus.gain.cancelScheduledValues(ctx.currentTime);
|
|
263
|
+
musicBus.gain.setValueAtTime(musicBus.gain.value, ctx.currentTime);
|
|
264
|
+
musicBus.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.8);
|
|
265
|
+
}
|
|
266
|
+
}
|