@onnie81/murdoku-spor 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/.dockerignore +7 -0
- package/Dockerfile +40 -0
- package/README.md +125 -0
- package/client/index.html +16 -0
- package/client/package.json +17 -0
- package/client/public/favicon.svg +14 -0
- package/client/public/manifest.webmanifest +12 -0
- package/client/src/App.jsx +62 -0
- package/client/src/api.js +40 -0
- package/client/src/art/logo.jsx +29 -0
- package/client/src/art/objects.jsx +158 -0
- package/client/src/art/portraits.jsx +197 -0
- package/client/src/components/Board.jsx +236 -0
- package/client/src/components/CluePanel.jsx +43 -0
- package/client/src/components/DailyView.jsx +114 -0
- package/client/src/components/FriendsView.jsx +112 -0
- package/client/src/components/HistoryView.jsx +80 -0
- package/client/src/components/HomeView.jsx +101 -0
- package/client/src/components/PlayView.jsx +323 -0
- package/client/src/components/SettingsView.jsx +134 -0
- package/client/src/components/Tray.jsx +48 -0
- package/client/src/difficulties.js +2 -0
- package/client/src/game.js +376 -0
- package/client/src/i18n/en.js +187 -0
- package/client/src/i18n/es.js +187 -0
- package/client/src/i18n/index.js +145 -0
- package/client/src/main.jsx +6 -0
- package/client/src/store.js +77 -0
- package/client/src/styles.css +374 -0
- package/client/vite.config.js +16 -0
- package/docker-compose.yml +21 -0
- package/murdoku.bundle +0 -0
- package/package.json +41 -0
- package/scripts/deploy.sh +49 -0
- package/scripts/npm-server-bootstrap.sh +53 -0
- package/scripts/npm-server-deploy.sh +61 -0
- package/scripts/server-bootstrap.sh +64 -0
- package/scripts/server-deploy.sh +40 -0
- package/server/api.js +354 -0
- package/server/auth.js +85 -0
- package/server/db.js +251 -0
- package/server/index.js +33 -0
- package/server/public/assets/index-COhad2G1.js +40 -0
- package/server/public/assets/index-h4AwMTqN.css +1 -0
- package/server/public/favicon.svg +14 -0
- package/server/public/index.html +17 -0
- package/server/public/manifest.webmanifest +12 -0
- package/server/puzzles.js +109 -0
- package/shared/bits.js +100 -0
- package/shared/clues.js +338 -0
- package/shared/generator.js +299 -0
- package/shared/model.js +127 -0
- package/shared/rng.js +51 -0
- package/shared/solver.js +262 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Original flat "art-deco cozy mystery" suspect portraits. All hand-drawn SVG.
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export const PERSON_COLORS = {
|
|
5
|
+
butler: '#6e56cf', chef: '#e5484d', heiress: '#e93d82', colonel: '#46a758',
|
|
6
|
+
professor: '#0090ff', gardener: '#f76b15', doctor: '#12a594', chauffeur: '#ad7f58',
|
|
7
|
+
diva: '#cf3897', journalist: '#ffc53d', magician: '#00a2c7', curator: '#bdee63'
|
|
8
|
+
};
|
|
9
|
+
export const DARK_TEXT_KEYS = new Set(['journalist', 'curator']);
|
|
10
|
+
|
|
11
|
+
const SKIN = {
|
|
12
|
+
butler: '#f2c9a0', chef: '#f2c9a0', heiress: '#f5d5b5', colonel: '#d9a066',
|
|
13
|
+
professor: '#a9714b', gardener: '#7c4f33', doctor: '#f2c9a0', chauffeur: '#d9a066',
|
|
14
|
+
diva: '#a9714b', journalist: '#f5d5b5', magician: '#d9a066', curator: '#f2c9a0'
|
|
15
|
+
};
|
|
16
|
+
const INK = '#241e33';
|
|
17
|
+
const COAT = '#2b2440';
|
|
18
|
+
|
|
19
|
+
function Base({ k, children }) {
|
|
20
|
+
const skin = SKIN[k];
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{/* shoulders / coat */}
|
|
24
|
+
<path d="M16,92 Q50,64 84,92 L84,102 L16,102 Z" fill={COAT} />
|
|
25
|
+
{/* neck + head */}
|
|
26
|
+
<rect x="43" y="56" width="14" height="16" rx="5" fill={skin} />
|
|
27
|
+
<ellipse cx="50" cy="45" rx="16.5" ry="18.5" fill={skin} />
|
|
28
|
+
{/* eyes */}
|
|
29
|
+
<circle cx="43.5" cy="46" r="1.9" fill={INK} />
|
|
30
|
+
<circle cx="56.5" cy="46" r="1.9" fill={INK} />
|
|
31
|
+
{children}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DETAILS = {
|
|
37
|
+
butler: (
|
|
38
|
+
<>
|
|
39
|
+
<path d="M33.5,42 Q34,26 50,26 Q66,26 66.5,42 Q64,32 50,31.5 Q36,32 33.5,42 Z" fill={INK} />
|
|
40
|
+
<circle cx="56.5" cy="46" r="5.4" fill="none" stroke="#c9a227" strokeWidth="1.6" />
|
|
41
|
+
<path d="M61.5,49 Q64,56 61,60" fill="none" stroke="#c9a227" strokeWidth="1.1" />
|
|
42
|
+
<path d="M40,78 L50,72 L60,78 L50,84 Z" fill="#efe6d0" />
|
|
43
|
+
<path d="M43,78 L50,74.5 L57,78 L50,81.5 Z" fill={INK} />
|
|
44
|
+
</>
|
|
45
|
+
),
|
|
46
|
+
chef: (
|
|
47
|
+
<>
|
|
48
|
+
<path d="M32,34 Q30,18 40,17 Q42,10 50,11 Q58,10 60,17 Q70,18 68,34 Z" fill="#f4efe2" />
|
|
49
|
+
<rect x="33" y="32" width="34" height="6" rx="2.5" fill="#e3dbc8" />
|
|
50
|
+
<path d="M38,76 Q50,68 62,76 L58,82 Q50,76 42,82 Z" fill="#e5484d" />
|
|
51
|
+
</>
|
|
52
|
+
),
|
|
53
|
+
heiress: (
|
|
54
|
+
<>
|
|
55
|
+
<ellipse cx="50" cy="29" rx="27" ry="7.5" fill="#3b2f54" />
|
|
56
|
+
<path d="M34,29 Q34,15 50,15 Q66,15 66,29 Z" fill="#3b2f54" />
|
|
57
|
+
<rect x="34" y="24" width="32" height="4.5" rx="2" fill="#e93d82" />
|
|
58
|
+
<path d="M35,45 Q37,58 44,63 L38,52 Z" fill="#8d4a2f" />
|
|
59
|
+
<path d="M65,45 Q63,58 56,63 L62,52 Z" fill="#8d4a2f" />
|
|
60
|
+
{[42, 46.5, 50, 53.5, 58].map((x, i) => (
|
|
61
|
+
<circle key={i} cx={x} cy={i % 2 ? 73 : 74.6} r="1.9" fill="#f4efe2" />
|
|
62
|
+
))}
|
|
63
|
+
</>
|
|
64
|
+
),
|
|
65
|
+
colonel: (
|
|
66
|
+
<>
|
|
67
|
+
<path d="M32,34 Q32,20 50,20 Q68,20 68,34 L68,37 L32,37 Z" fill="#3d4a33" />
|
|
68
|
+
<path d="M30,37 L70,37 L66,42 L34,42 Z" fill="#2c3625" />
|
|
69
|
+
<circle cx="50" cy="28" r="3" fill="#c9a227" />
|
|
70
|
+
<path d="M38,54 Q44,50 50,54 Q56,50 62,54 Q58,60 50,57 Q42,60 38,54 Z" fill="#cbc4d4" />
|
|
71
|
+
<rect x="18" y="84" width="12" height="4" rx="2" fill="#c9a227" />
|
|
72
|
+
<rect x="70" y="84" width="12" height="4" rx="2" fill="#c9a227" />
|
|
73
|
+
</>
|
|
74
|
+
),
|
|
75
|
+
professor: (
|
|
76
|
+
<>
|
|
77
|
+
<circle cx="50" cy="20" r="7.5" fill="#5d4037" />
|
|
78
|
+
<path d="M33.5,42 Q34,26 50,26 Q66,26 66.5,42 Q62,31 50,30.5 Q38,31 33.5,42 Z" fill="#5d4037" />
|
|
79
|
+
<circle cx="43.5" cy="46" r="5.2" fill="none" stroke={INK} strokeWidth="1.7" />
|
|
80
|
+
<circle cx="56.5" cy="46" r="5.2" fill="none" stroke={INK} strokeWidth="1.7" />
|
|
81
|
+
<line x1="48.7" y1="46" x2="51.3" y2="46" stroke={INK} strokeWidth="1.7" />
|
|
82
|
+
<path d="M42,76 L50,71 L58,76 L50,80 Z" fill="#0090ff" />
|
|
83
|
+
</>
|
|
84
|
+
),
|
|
85
|
+
gardener: (
|
|
86
|
+
<>
|
|
87
|
+
<ellipse cx="50" cy="31" rx="26" ry="6.5" fill="#d9b566" />
|
|
88
|
+
<path d="M36,31 Q36,17 50,17 Q64,17 64,31 Z" fill="#d9b566" />
|
|
89
|
+
<path d="M36,29 L64,29 L64,32 L36,32 Z" fill="#8d6b30" />
|
|
90
|
+
<path d="M38,74 Q50,68 62,74 L60,80 Q50,73 40,80 Z" fill="#f76b15" />
|
|
91
|
+
<circle cx="50" cy="77" r="2.4" fill="#c14f0e" />
|
|
92
|
+
</>
|
|
93
|
+
),
|
|
94
|
+
doctor: (
|
|
95
|
+
<>
|
|
96
|
+
<path d="M33,48 Q31,26 50,26 Q69,26 67,48 Q66,34 50,33 Q34,34 33,48 Z" fill="#8d4a2f" />
|
|
97
|
+
<path d="M33,48 Q35,54 40,52 L36,44 Z" fill="#8d4a2f" />
|
|
98
|
+
<path d="M67,48 Q65,54 60,52 L64,44 Z" fill="#8d4a2f" />
|
|
99
|
+
<circle cx="44" cy="46.5" r="4.6" fill="none" stroke="#c9a227" strokeWidth="1.5" />
|
|
100
|
+
<circle cx="56" cy="46.5" r="4.6" fill="none" stroke="#c9a227" strokeWidth="1.5" />
|
|
101
|
+
<path d="M48.5,46.5 L51.5,46.5" stroke="#c9a227" strokeWidth="1.5" />
|
|
102
|
+
<path d="M56,51 Q60,58 58,64" stroke="#c9a227" strokeWidth="1.1" fill="none" />
|
|
103
|
+
<circle cx="50" cy="78" r="3.2" fill="#12a594" />
|
|
104
|
+
</>
|
|
105
|
+
),
|
|
106
|
+
chauffeur: (
|
|
107
|
+
<>
|
|
108
|
+
<path d="M32,33 Q32,20 50,20 Q68,20 68,33 L68,35 L32,35 Z" fill="#41597a" />
|
|
109
|
+
<path d="M46,35 L74,35 L70,40 L46,38 Z" fill="#31435c" />
|
|
110
|
+
<circle cx="42" cy="27" r="5" fill="none" stroke="#b8c4d4" strokeWidth="2.2" />
|
|
111
|
+
<circle cx="55" cy="27" r="5" fill="none" stroke="#b8c4d4" strokeWidth="2.2" />
|
|
112
|
+
<path d="M40,76 Q50,70 60,76 Q55,80 50,78 Q45,80 40,76 Z" fill="#cbc4d4" />
|
|
113
|
+
</>
|
|
114
|
+
),
|
|
115
|
+
diva: (
|
|
116
|
+
<>
|
|
117
|
+
<path d="M50,22 Q46,8 34,6 Q44,4 52,10 Q50,4 58,2 Q54,8 56,14 Q60,8 68,8 Q58,12 56,22 Z" fill="#cf3897" />
|
|
118
|
+
<path d="M34,40 Q34,24 50,24 Q66,24 66,40 Q60,29 50,29 Q40,29 34,40 Z" fill={INK} />
|
|
119
|
+
<rect x="36" y="35" width="28" height="3.6" rx="1.8" fill="#c9a227" />
|
|
120
|
+
<circle cx="50" cy="36.8" r="2.6" fill="#e93d82" />
|
|
121
|
+
<path d="M33,46 L33,58 M67,46 L67,58" stroke="#c9a227" strokeWidth="2" />
|
|
122
|
+
<circle cx="33" cy="60" r="2.4" fill="#c9a227" />
|
|
123
|
+
<circle cx="67" cy="60" r="2.4" fill="#c9a227" />
|
|
124
|
+
</>
|
|
125
|
+
),
|
|
126
|
+
journalist: (
|
|
127
|
+
<>
|
|
128
|
+
<path d="M31,36 Q31,19 50,19 Q69,19 69,36 L69,38 L31,38 Z" fill="#7a4b2a" />
|
|
129
|
+
<ellipse cx="50" cy="38" rx="24" ry="4.5" fill="#5d3820" />
|
|
130
|
+
<rect x="55" y="24" width="9" height="10" rx="1" fill="#efe6d0" transform="rotate(8 59.5 29)" />
|
|
131
|
+
<path d="M57,27 L62,27 M57,29.5 L62,29.5 M57,32 L60,32" stroke={INK} strokeWidth="0.9" transform="rotate(8 59.5 29)" />
|
|
132
|
+
<path d="M33,44 Q33,56 40,60 L36,47 Z" fill="#7a4b2a" />
|
|
133
|
+
<path d="M67,44 Q67,56 60,60 L64,47 Z" fill="#7a4b2a" />
|
|
134
|
+
</>
|
|
135
|
+
),
|
|
136
|
+
magician: (
|
|
137
|
+
<>
|
|
138
|
+
<rect x="36" y="6" width="28" height="22" rx="2" fill={INK} />
|
|
139
|
+
<rect x="36" y="22" width="28" height="5" fill="#00a2c7" />
|
|
140
|
+
<ellipse cx="50" cy="29" rx="21" ry="4" fill={INK} />
|
|
141
|
+
<path d="M40,52 Q44,55 47,52 M53,52 Q56,55 60,52" stroke={INK} strokeWidth="1.6" fill="none" />
|
|
142
|
+
<path d="M41,77 L50,72 L59,77 L50,82 Z" fill="#00a2c7" />
|
|
143
|
+
</>
|
|
144
|
+
),
|
|
145
|
+
curator: (
|
|
146
|
+
<>
|
|
147
|
+
<path d="M31,33 Q30,18 48,17 Q70,15 67,28 Q60,22 50,24 Q36,26 36,36 Z" fill="#4a3a63" />
|
|
148
|
+
<circle cx="66" cy="24" r="3" fill="#4a3a63" />
|
|
149
|
+
<circle cx="43.5" cy="46" r="5" fill="none" stroke={INK} strokeWidth="1.5" />
|
|
150
|
+
<circle cx="56.5" cy="46" r="5" fill="none" stroke={INK} strokeWidth="1.5" />
|
|
151
|
+
<path d="M48.5,46 L51.5,46 M38.5,46 L34,44" stroke={INK} strokeWidth="1.5" />
|
|
152
|
+
<path d="M46,58 Q50,63 54,58 Q52,65 50,65 Q48,65 46,58 Z" fill="#4a3a63" />
|
|
153
|
+
<path d="M40,77 L60,77 L58,81 L42,81 Z" fill="#bdee63" />
|
|
154
|
+
</>
|
|
155
|
+
)
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function Portrait({ k, size = 44, victim = false, className = '', title }) {
|
|
159
|
+
const color = PERSON_COLORS[k] || '#888';
|
|
160
|
+
return (
|
|
161
|
+
<svg
|
|
162
|
+
viewBox="0 0 100 100" width={size} height={size}
|
|
163
|
+
className={`portrait ${victim ? 'portrait-victim' : ''} ${className}`}
|
|
164
|
+
role="img" aria-label={title || k}
|
|
165
|
+
>
|
|
166
|
+
<defs>
|
|
167
|
+
<clipPath id={`pc-${k}`}><circle cx="50" cy="50" r="48" /></clipPath>
|
|
168
|
+
</defs>
|
|
169
|
+
<circle cx="50" cy="50" r="48" fill={color} opacity={victim ? 0.45 : 1} />
|
|
170
|
+
<circle cx="50" cy="50" r="48" fill="#1a1526" opacity="0.18" />
|
|
171
|
+
<g clipPath={`url(#pc-${k})`} filter={victim ? 'grayscale(0.9)' : undefined}>
|
|
172
|
+
<Base k={k}>{DETAILS[k]}</Base>
|
|
173
|
+
{victim && (
|
|
174
|
+
<g stroke="#241e33" strokeWidth="2" opacity="0.9">
|
|
175
|
+
<path d="M40.5,44 L46.5,48 M46.5,44 L40.5,48" />
|
|
176
|
+
<path d="M53.5,44 L59.5,48 M59.5,44 L53.5,48" />
|
|
177
|
+
</g>
|
|
178
|
+
)}
|
|
179
|
+
</g>
|
|
180
|
+
<circle cx="50" cy="50" r="47" fill="none" stroke="#12101c" strokeWidth="2.5" opacity="0.55" />
|
|
181
|
+
</svg>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function NumberBadge({ n, k, size = 16 }) {
|
|
186
|
+
const dark = DARK_TEXT_KEYS.has(k);
|
|
187
|
+
return (
|
|
188
|
+
<span
|
|
189
|
+
className="num-badge"
|
|
190
|
+
style={{
|
|
191
|
+
width: size, height: size, fontSize: size * 0.62,
|
|
192
|
+
background: PERSON_COLORS[k] || '#888',
|
|
193
|
+
color: dark ? '#241e33' : '#fff'
|
|
194
|
+
}}
|
|
195
|
+
>{n}</span>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The crime-scene board: rooms, walls, furniture, people, X marks, pencil
|
|
2
|
+
// marks, legality graying, clue spotlights. Pure SVG, pointer-driven.
|
|
3
|
+
import React, { forwardRef, useImperativeHandle, useRef, useMemo } from 'react';
|
|
4
|
+
import { Portrait, PERSON_COLORS, DARK_TEXT_KEYS } from '../art/portraits.jsx';
|
|
5
|
+
import { ObjectIcon } from '../art/objects.jsx';
|
|
6
|
+
import { roomName } from '../i18n/index.js';
|
|
7
|
+
|
|
8
|
+
const CS = 100; // cell size in svg units
|
|
9
|
+
const M = 6; // outer margin
|
|
10
|
+
|
|
11
|
+
export const ROOM_COLORS = [
|
|
12
|
+
'#4e4370', '#3e5644', '#5e4141', '#39586e',
|
|
13
|
+
'#5f5535', '#35594f', '#5d3d5d', '#4d5533'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const NOTE_POS = [
|
|
17
|
+
[22, 22], [50, 22], [78, 22],
|
|
18
|
+
[22, 50], [50, 50], [78, 50],
|
|
19
|
+
[22, 78], [50, 78], [78, 78]
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const Board = forwardRef(function Board(
|
|
23
|
+
{ store, snap, marks = 'numbers', dragTarget = null, onCellPointerDown },
|
|
24
|
+
ref
|
|
25
|
+
) {
|
|
26
|
+
const { puzzle, N, objCells } = store;
|
|
27
|
+
const svgRef = useRef(null);
|
|
28
|
+
const size = N * CS + M * 2;
|
|
29
|
+
|
|
30
|
+
useImperativeHandle(ref, () => ({
|
|
31
|
+
cellFromPoint(clientX, clientY) {
|
|
32
|
+
const el = svgRef.current;
|
|
33
|
+
if (!el) return null;
|
|
34
|
+
const r = el.getBoundingClientRect();
|
|
35
|
+
const u = ((clientX - r.left) / r.width) * size - M;
|
|
36
|
+
const v = ((clientY - r.top) / r.height) * size - M;
|
|
37
|
+
const c = Math.floor(u / CS), row = Math.floor(v / CS);
|
|
38
|
+
if (c < 0 || row < 0 || c >= N || row >= N) return null;
|
|
39
|
+
return row * N + c;
|
|
40
|
+
},
|
|
41
|
+
el: () => svgRef.current
|
|
42
|
+
}), [N, size]);
|
|
43
|
+
|
|
44
|
+
const roomOf = useMemo(() => {
|
|
45
|
+
const arr = new Array(N * N).fill(0);
|
|
46
|
+
for (const r of puzzle.data.rooms) for (const c of r.cells) arr[c] = r.id;
|
|
47
|
+
return arr;
|
|
48
|
+
}, [puzzle, N]);
|
|
49
|
+
|
|
50
|
+
const roomAnchor = useMemo(() => {
|
|
51
|
+
const m = new Map();
|
|
52
|
+
for (const r of puzzle.data.rooms) m.set(r.id, Math.min(...r.cells));
|
|
53
|
+
return m;
|
|
54
|
+
}, [puzzle]);
|
|
55
|
+
|
|
56
|
+
const cellOwner = useMemo(() => {
|
|
57
|
+
const m = new Map();
|
|
58
|
+
for (const [p, c] of Object.entries(snap.placed)) m.set(c, Number(p));
|
|
59
|
+
return m;
|
|
60
|
+
}, [snap.placed]);
|
|
61
|
+
|
|
62
|
+
// legality view: which cells gray out for the active person
|
|
63
|
+
const activePerson = dragTarget != null ? dragTarget : snap.selected;
|
|
64
|
+
const legalSet = useMemo(
|
|
65
|
+
() => (activePerson != null && snap.status === 'active' ? store.legalCells(activePerson) : null),
|
|
66
|
+
[activePerson, snap.placed, snap.xmarks, snap.status] // eslint-disable-line react-hooks/exhaustive-deps
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const hl = snap.highlight;
|
|
70
|
+
const hlCells = useMemo(() => new Set(hl?.cells || []), [hl]);
|
|
71
|
+
const hlRooms = useMemo(() => new Set(hl?.rooms || []), [hl]);
|
|
72
|
+
const hlPersons = useMemo(() => new Set(hl?.persons || []), [hl]);
|
|
73
|
+
const xset = useMemo(() => new Set(snap.xmarks), [snap.xmarks]);
|
|
74
|
+
const wrongSet = useMemo(() => new Set(snap.wrongCells), [snap.wrongCells]);
|
|
75
|
+
|
|
76
|
+
const cells = [];
|
|
77
|
+
for (let cell = 0; cell < N * N; cell++) {
|
|
78
|
+
const r = Math.floor(cell / N), c = cell % N;
|
|
79
|
+
const x = M + c * CS, y = M + r * CS;
|
|
80
|
+
const room = roomOf[cell];
|
|
81
|
+
cells.push({ cell, r, c, x, y, room });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// walls between rooms
|
|
85
|
+
const walls = [];
|
|
86
|
+
for (const { cell, r, c, x, y, room } of cells) {
|
|
87
|
+
if (c < N - 1 && roomOf[cell + 1] !== room) walls.push(<line key={`v${cell}`} x1={x + CS} y1={y - 1} x2={x + CS} y2={y + CS + 1} />);
|
|
88
|
+
if (r < N - 1 && roomOf[cell + N] !== room) walls.push(<line key={`h${cell}`} x1={x - 1} y1={y + CS} x2={x + CS + 1} y2={y + CS} />);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<svg
|
|
93
|
+
ref={svgRef}
|
|
94
|
+
className="board"
|
|
95
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
96
|
+
role="grid"
|
|
97
|
+
aria-label="crime scene"
|
|
98
|
+
>
|
|
99
|
+
{/* room fills */}
|
|
100
|
+
{cells.map(({ cell, x, y, room }) => (
|
|
101
|
+
<rect key={cell} x={x} y={y} width={CS} height={CS}
|
|
102
|
+
fill={ROOM_COLORS[room % ROOM_COLORS.length]}
|
|
103
|
+
className={hlRooms.has(room) ? 'room-hl' : undefined} />
|
|
104
|
+
))}
|
|
105
|
+
{/* subtle inner grid */}
|
|
106
|
+
{Array.from({ length: N - 1 }, (_, i) => (
|
|
107
|
+
<g key={i} stroke="#00000030" strokeWidth="1.5">
|
|
108
|
+
<line x1={M + (i + 1) * CS} y1={M} x2={M + (i + 1) * CS} y2={size - M} />
|
|
109
|
+
<line x1={M} y1={M + (i + 1) * CS} x2={size - M} y2={M + (i + 1) * CS} />
|
|
110
|
+
</g>
|
|
111
|
+
))}
|
|
112
|
+
{/* clue spotlight on cells */}
|
|
113
|
+
{cells.filter(({ cell }) => hlCells.has(cell)).map(({ cell, x, y }) => (
|
|
114
|
+
<rect key={`hl${cell}`} x={x + 2} y={y + 2} width={CS - 4} height={CS - 4}
|
|
115
|
+
className="cell-spot" rx="8" />
|
|
116
|
+
))}
|
|
117
|
+
{/* walls */}
|
|
118
|
+
<g stroke="#161022" strokeWidth="6" strokeLinecap="square">{walls}</g>
|
|
119
|
+
<rect x={M} y={M} width={N * CS} height={N * CS} fill="none" stroke="#161022" strokeWidth="10" rx="4" />
|
|
120
|
+
|
|
121
|
+
{/* room labels */}
|
|
122
|
+
{puzzle.data.rooms.map(room => {
|
|
123
|
+
const anchor = roomAnchor.get(room.id);
|
|
124
|
+
const r = Math.floor(anchor / N), c = anchor % N;
|
|
125
|
+
return (
|
|
126
|
+
<text key={room.id} x={M + c * CS + 8} y={M + r * CS + 20}
|
|
127
|
+
className="room-label">{roomName(puzzle, room.id, 'n').toUpperCase()}</text>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
|
|
131
|
+
{/* objects */}
|
|
132
|
+
{puzzle.data.objects.map(o => {
|
|
133
|
+
const r = Math.floor(o.cell / N), c = o.cell % N;
|
|
134
|
+
return (
|
|
135
|
+
<g key={o.id} className="obj" transform={`translate(${M + c * CS + 14},${M + r * CS + 16})`}>
|
|
136
|
+
<ObjectIcon type={o.type} size={72} />
|
|
137
|
+
</g>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
|
|
141
|
+
{/* X marks */}
|
|
142
|
+
{snap.xmarks.map(cell => {
|
|
143
|
+
const r = Math.floor(cell / N), c = cell % N;
|
|
144
|
+
const x = M + c * CS, y = M + r * CS;
|
|
145
|
+
return (
|
|
146
|
+
<g key={`x${cell}`} className="xmark" strokeLinecap="round">
|
|
147
|
+
<line x1={x + 30} y1={y + 30} x2={x + 70} y2={y + 70} />
|
|
148
|
+
<line x1={x + 70} y1={y + 30} x2={x + 30} y2={y + 70} />
|
|
149
|
+
</g>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
|
|
153
|
+
{/* pencil marks */}
|
|
154
|
+
{Object.entries(snap.notes).map(([cellStr, list]) => {
|
|
155
|
+
const cell = Number(cellStr);
|
|
156
|
+
const r = Math.floor(cell / N), c = cell % N;
|
|
157
|
+
const x = M + c * CS, y = M + r * CS;
|
|
158
|
+
return (
|
|
159
|
+
<g key={`n${cell}`}>
|
|
160
|
+
{list.slice(0, 9).map((p, i) => {
|
|
161
|
+
const [nx, ny] = NOTE_POS[i];
|
|
162
|
+
const k = puzzle.data.cast[p]?.key;
|
|
163
|
+
if (marks === 'faces') {
|
|
164
|
+
return (
|
|
165
|
+
<g key={p} transform={`translate(${x + nx - 13},${y + ny - 13})`} opacity="0.92">
|
|
166
|
+
<Portrait k={k} size={26} victim={p === puzzle.data.victim} />
|
|
167
|
+
</g>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<g key={p}>
|
|
172
|
+
<circle cx={x + nx} cy={y + ny} r="12.5" fill={PERSON_COLORS[k]} opacity="0.92" />
|
|
173
|
+
<text x={x + nx} y={y + ny + 5.2} textAnchor="middle" className="note-num"
|
|
174
|
+
fill={DARK_TEXT_KEYS.has(k) ? '#241e33' : '#fff'}>{p + 1}</text>
|
|
175
|
+
</g>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</g>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
|
|
182
|
+
{/* placed persons */}
|
|
183
|
+
{[...cellOwner.entries()].map(([cell, p]) => {
|
|
184
|
+
const r = Math.floor(cell / N), c = cell % N;
|
|
185
|
+
const x = M + c * CS, y = M + r * CS;
|
|
186
|
+
const k = puzzle.data.cast[p]?.key;
|
|
187
|
+
const isVictim = p === puzzle.data.victim;
|
|
188
|
+
const selected = snap.selected === p;
|
|
189
|
+
const wrong = wrongSet.has(cell);
|
|
190
|
+
const spotted = hlPersons.has(p);
|
|
191
|
+
return (
|
|
192
|
+
<g key={`p${p}`} className={`piece${selected ? ' piece-sel' : ''}${dragTarget === p ? ' piece-drag' : ''}`}>
|
|
193
|
+
{(selected || spotted) && (
|
|
194
|
+
<circle cx={x + 50} cy={y + 50} r="45" className={spotted && !selected ? 'ring-spot' : 'ring-sel'} />
|
|
195
|
+
)}
|
|
196
|
+
{wrong && <circle cx={x + 50} cy={y + 50} r="44" className="ring-wrong" />}
|
|
197
|
+
<g transform={`translate(${x + 11},${y + 11})`}>
|
|
198
|
+
<Portrait k={k} size={78} victim={isVictim} />
|
|
199
|
+
</g>
|
|
200
|
+
<circle cx={x + 82} cy={y + 20} r="13" fill={isVictim ? '#8d1d2c' : PERSON_COLORS[k]} stroke="#161022" strokeWidth="2" />
|
|
201
|
+
<text x={x + 82} y={y + 25.5} textAnchor="middle" className="piece-num"
|
|
202
|
+
fill={!isVictim && DARK_TEXT_KEYS.has(k) ? '#241e33' : '#fff'}>
|
|
203
|
+
{isVictim ? 'V' : p + 1}
|
|
204
|
+
</text>
|
|
205
|
+
</g>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
|
|
209
|
+
{/* graying of non-allowed positions for the active person */}
|
|
210
|
+
{legalSet && cells.map(({ cell, x, y }) => {
|
|
211
|
+
if (legalSet.has(cell)) return null;
|
|
212
|
+
if (cellOwner.get(cell) === activePerson) return null;
|
|
213
|
+
return <rect key={`g${cell}`} x={x} y={y} width={CS} height={CS} className="cell-gray" />;
|
|
214
|
+
})}
|
|
215
|
+
{/* legal glow */}
|
|
216
|
+
{legalSet && cells.filter(({ cell }) => legalSet.has(cell)).map(({ cell, x, y }) => (
|
|
217
|
+
<rect key={`l${cell}`} x={x + 3} y={y + 3} width={CS - 6} height={CS - 6}
|
|
218
|
+
rx="10" className="cell-legal" />
|
|
219
|
+
))}
|
|
220
|
+
{/* illegal tap flash */}
|
|
221
|
+
{snap.flashCell != null && (() => {
|
|
222
|
+
const r = Math.floor(snap.flashCell / N), c = snap.flashCell % N;
|
|
223
|
+
return <rect x={M + c * CS + 3} y={M + r * CS + 3} width={CS - 6} height={CS - 6}
|
|
224
|
+
rx="10" className="cell-flash" />;
|
|
225
|
+
})()}
|
|
226
|
+
|
|
227
|
+
{/* hit layer */}
|
|
228
|
+
{cells.map(({ cell, x, y }) => (
|
|
229
|
+
<rect key={`t${cell}`} x={x} y={y} width={CS} height={CS} fill="transparent"
|
|
230
|
+
style={{ cursor: 'pointer' }}
|
|
231
|
+
onPointerDown={(e) => onCellPointerDown?.(cell, e)}
|
|
232
|
+
onContextMenu={(e) => { e.preventDefault(); store.actions.toggleX(cell); }} />
|
|
233
|
+
))}
|
|
234
|
+
</svg>
|
|
235
|
+
);
|
|
236
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Testimony list: tap to spotlight on the board, tap the badge to cross off.
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Portrait } from '../art/portraits.jsx';
|
|
4
|
+
import { clueText, t } from '../i18n/index.js';
|
|
5
|
+
|
|
6
|
+
export function CluePanel({ store, snap }) {
|
|
7
|
+
const { puzzle } = store;
|
|
8
|
+
return (
|
|
9
|
+
<div className="clue-panel">
|
|
10
|
+
<div className="clue-head">
|
|
11
|
+
<h3>{t('clues')}</h3>
|
|
12
|
+
<span className="clue-hint">{t('clueTapHint')}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<ul className="clue-list">
|
|
15
|
+
{puzzle.data.clues.map(clue => {
|
|
16
|
+
const struck = snap.struck.includes(clue.id);
|
|
17
|
+
const active = snap.highlight?.clueId === clue.id;
|
|
18
|
+
const subjects = [clue.s, clue.t].filter(v => v != null);
|
|
19
|
+
return (
|
|
20
|
+
<li key={clue.id}
|
|
21
|
+
className={`clue${struck ? ' clue-struck' : ''}${active ? ' clue-active' : ''}`}>
|
|
22
|
+
<button
|
|
23
|
+
className="clue-check"
|
|
24
|
+
aria-label="strike"
|
|
25
|
+
onClick={(e) => { e.stopPropagation(); store.actions.strike(clue.id); }}
|
|
26
|
+
>{struck ? '✓' : ''}</button>
|
|
27
|
+
<button className="clue-body" onClick={() => store.actions.highlightClue(clue)}>
|
|
28
|
+
<span className="clue-faces">
|
|
29
|
+
{subjects.length
|
|
30
|
+
? subjects.map(p => (
|
|
31
|
+
<Portrait key={p} k={puzzle.data.cast[p]?.key} size={26} />
|
|
32
|
+
))
|
|
33
|
+
: <span className="clue-glass">🔍</span>}
|
|
34
|
+
</span>
|
|
35
|
+
<span className="clue-text">{clueText(clue, puzzle)}</span>
|
|
36
|
+
</button>
|
|
37
|
+
</li>
|
|
38
|
+
);
|
|
39
|
+
})}
|
|
40
|
+
</ul>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { useApp, navigate, toast } from '../store.js';
|
|
4
|
+
import { t, fmtTime, fmtDate } from '../i18n/index.js';
|
|
5
|
+
|
|
6
|
+
export function DailyView() {
|
|
7
|
+
const app = useApp();
|
|
8
|
+
const [date, setDate] = useState(null);
|
|
9
|
+
const [daily, setDaily] = useState(null);
|
|
10
|
+
const [board, setBoard] = useState(null); // {difficulty, scope, data}
|
|
11
|
+
|
|
12
|
+
const load = (d) => {
|
|
13
|
+
api.daily(d).then(res => {
|
|
14
|
+
setDaily(res);
|
|
15
|
+
setDate(res.date);
|
|
16
|
+
}).catch(() => toast(t('errGeneric')));
|
|
17
|
+
};
|
|
18
|
+
useEffect(() => { load(); }, []);
|
|
19
|
+
|
|
20
|
+
const openBoard = async (difficulty, scope) => {
|
|
21
|
+
try {
|
|
22
|
+
const data = await api.leaderboard(date, difficulty, scope);
|
|
23
|
+
setBoard({ difficulty, scope, data });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
toast(e.code === 'login_required' ? t('friendsLoginNote') : t('errGeneric'));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const play = async (difficulty) => {
|
|
30
|
+
try {
|
|
31
|
+
const res = await api.newGame('daily', difficulty, date);
|
|
32
|
+
navigate(`#/play/${res.game.id}`);
|
|
33
|
+
} catch { toast(t('errGeneric')); }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (!daily) return <div className="view-pad loading">{t('loading')}</div>;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="view">
|
|
40
|
+
<div className="card-head view-head">
|
|
41
|
+
<h1>{t('dailyTitle')}</h1>
|
|
42
|
+
<p className="muted">{t('dailySub')}</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="card">
|
|
46
|
+
<div className="archive-row">
|
|
47
|
+
<label className="muted" htmlFor="daily-date">{t('archive')}:</label>
|
|
48
|
+
<input
|
|
49
|
+
id="daily-date" type="date" value={date || ''}
|
|
50
|
+
max={daily.today}
|
|
51
|
+
onChange={e => { if (e.target.value) load(e.target.value); }}
|
|
52
|
+
/>
|
|
53
|
+
{date !== daily.today && (
|
|
54
|
+
<button className="link-btn" onClick={() => load(daily.today)}>{t('todayIs')} →</button>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{daily.tiers.map(tier => (
|
|
60
|
+
<div key={tier.difficulty} className="card daily-tier">
|
|
61
|
+
<div className="daily-tier-row">
|
|
62
|
+
<div className="daily-tier-info">
|
|
63
|
+
<b>{t(tier.difficulty)}</b>
|
|
64
|
+
<span className="muted"> · {tier.size}×{tier.size} · {t('playersSolved', { n: tier.solvedCount })}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="daily-tier-actions">
|
|
67
|
+
{tier.status === 'won'
|
|
68
|
+
? <span className="won-chip">✓ {fmtTime(tier.timeMs)}</span>
|
|
69
|
+
: <button className="btn btn-sm btn-gold" onClick={() => play(tier.difficulty)}>
|
|
70
|
+
{tier.status === 'active' ? t('resume') : t('play')}
|
|
71
|
+
</button>}
|
|
72
|
+
<button className="btn btn-sm" onClick={() => openBoard(tier.difficulty, 'global')}>
|
|
73
|
+
{t('leaderboard')}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{board && board.difficulty === tier.difficulty && (
|
|
79
|
+
<div className="lb">
|
|
80
|
+
<div className="lb-tabs">
|
|
81
|
+
<button className={board.scope === 'global' ? 'on' : ''}
|
|
82
|
+
onClick={() => openBoard(tier.difficulty, 'global')}>{t('global')}</button>
|
|
83
|
+
<button className={board.scope === 'friends' ? 'on' : ''}
|
|
84
|
+
onClick={() => openBoard(tier.difficulty, 'friends')}>{t('friendsScope')}</button>
|
|
85
|
+
</div>
|
|
86
|
+
{board.data.rows.length === 0
|
|
87
|
+
? <p className="muted">{t('noResults')}</p>
|
|
88
|
+
: (
|
|
89
|
+
<table className="lb-table">
|
|
90
|
+
<tbody>
|
|
91
|
+
{board.data.rows.map(r => (
|
|
92
|
+
<tr key={r.rank} className={app.me && r.username === app.me.username ? 'lb-me' : ''}>
|
|
93
|
+
<td className="lb-rank">#{r.rank}</td>
|
|
94
|
+
<td>{r.username}</td>
|
|
95
|
+
<td className="lb-time">{fmtTime(r.timeMs)}</td>
|
|
96
|
+
<td className="lb-mist">✗{r.mistakes}</td>
|
|
97
|
+
</tr>
|
|
98
|
+
))}
|
|
99
|
+
</tbody>
|
|
100
|
+
</table>
|
|
101
|
+
)}
|
|
102
|
+
{board.data.me && (
|
|
103
|
+
<p className="muted lb-mine">
|
|
104
|
+
{t('yourTime')}: {fmtTime(board.data.me.timeMs)}
|
|
105
|
+
{board.data.me.rank ? ` · #${board.data.me.rank}` : ` (${t('unranked')})`}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|