28 Commits

Author SHA1 Message Date
ca58b68d7c 1.9.5 2026-02-11 07:01:33 +01:00
6854fe39e5 Refactor: Extract utils, cleanup timer logic, fix share screenshot 2026-02-11 07:01:32 +01:00
d68c4a6c3d 1.9.4 2026-02-11 06:27:42 +01:00
23c9137985 fix: update win condition to validate hints instead of exact match 2026-02-11 06:27:42 +01:00
c3ddadeb17 1.9.3 2026-02-11 06:14:19 +01:00
36b0c18370 fix: improve desktop layout, scrolling and background 2026-02-11 06:14:19 +01:00
943c045a65 1.9.2 2026-02-11 06:00:37 +01:00
27a1ce38fb fix: rename share classes to avoid adblockers 2026-02-11 06:00:37 +01:00
63dd833f2a 1.9.1 2026-02-11 05:49:48 +01:00
fa8384c860 feat(layout): use native browser scroll for game board on desktop 2026-02-11 05:49:47 +01:00
430f370196 1.9.0 2026-02-11 05:31:50 +01:00
c8789741fc feat(guide): update playback speed options (remove 3x, add 8x/16x) 2026-02-11 05:31:49 +01:00
53ef63f25f 1.8.5 2026-02-11 05:24:12 +01:00
9b869bdb5f perf(puzzle): optimize undo logic and dragging performance 2026-02-11 05:22:32 +01:00
764763016b 1.8.4 2026-02-11 05:00:18 +01:00
5cfabf40e8 feat(i18n): add simulation translations for all languages 2026-02-11 05:00:16 +01:00
460de9fac9 1.8.3 2026-02-11 04:50:21 +01:00
a264ac75e3 perf(CustomGameModal): optimize difficulty map rendering with caching 2026-02-11 04:50:13 +01:00
43822d03ac 1.8.2 2026-02-11 04:47:09 +01:00
2e83d3ea9f feat(i18n): add translations for difficulty simulation feature 2026-02-11 04:47:02 +01:00
0023190f5a 1.8.1 2026-02-11 04:32:39 +01:00
2552ea9423 fix(CustomGameModal): improve difficulty map UX - resize and drag outside 2026-02-11 04:32:32 +01:00
e08be0574d 1.8.0 2026-02-11 04:14:12 +01:00
3797e7715f feat(difficulty): implement Monte Carlo simulation for accurate difficulty calculation 2026-02-11 04:14:06 +01:00
19c4516d22 1.7.0 2026-02-11 03:47:30 +01:00
06c345e8f0 fix(CustomGameModal): improve layout of difficulty level and percentage 2026-02-11 03:47:24 +01:00
d9ae630fe5 1.6.4 2026-02-11 03:03:15 +01:00
132c4ebced feat: enhance custom game difficulty calculation and UI 2026-02-11 03:03:14 +01:00
28 changed files with 4288 additions and 686 deletions

37
check_i18n.js Normal file
View File

@@ -0,0 +1,37 @@
const fs = require('fs');
const fileContent = fs.readFileSync('src/composables/useI18n.js', 'utf8');
// Extract the messages object
const match = fileContent.match(/const messages = ({[\s\S]*?});/);
if (!match) {
console.error('Could not find messages object');
process.exit(1);
}
// We need to make the string valid JS to eval it.
// It seems the content inside `const messages = { ... };` is valid JS object notation.
// But we need to be careful about imports or other things if we were to `eval` the whole file.
// We'll just `eval` the object part.
const messagesStr = match[1];
const messages = eval(`(${messagesStr})`);
const enKeys = Object.keys(messages.en);
const languages = Object.keys(messages);
const missing = {};
languages.forEach(lang => {
if (lang === 'en') return;
const langKeys = Object.keys(messages[lang]);
const missingKeys = enKeys.filter(k => !langKeys.includes(k));
if (missingKeys.length > 0) {
missing[lang] = missingKeys;
}
});
console.log(JSON.stringify(missing, null, 2));

View File

@@ -0,0 +1,118 @@
size,density,avg_solved_percent,min_solved_percent,max_solved_percent,avg_time_ms
5,0.1,89.40,36.00,100.00,0.03
5,0.2,74.20,8.00,100.00,0.04
5,0.3,74.20,0.00,100.00,0.04
5,0.4,80.80,8.00,100.00,0.03
5,0.5,96.80,68.00,100.00,0.03
5,0.6,97.60,84.00,100.00,0.03
5,0.7,99.20,84.00,100.00,0.03
5,0.8,100.00,100.00,100.00,0.02
5,0.9,100.00,100.00,100.00,0.02
10,0.1,56.60,19.00,86.00,0.04
10,0.2,19.80,0.00,51.00,0.05
10,0.3,15.75,0.00,73.00,0.07
10,0.4,54.05,0.00,100.00,0.13
10,0.5,91.80,59.00,100.00,0.17
10,0.6,99.80,96.00,100.00,0.07
10,0.7,99.80,96.00,100.00,0.05
10,0.8,100.00,100.00,100.00,0.04
10,0.9,100.00,100.00,100.00,0.02
15,0.1,37.04,13.33,61.78,0.05
15,0.2,9.78,0.00,26.67,0.03
15,0.3,1.89,0.00,8.00,0.03
15,0.4,11.82,0.00,61.33,0.08
15,0.5,68.20,2.67,100.00,0.14
15,0.6,99.56,96.44,100.00,0.09
15,0.7,99.78,97.33,100.00,0.07
15,0.8,100.00,100.00,100.00,0.05
15,0.9,100.00,100.00,100.00,0.03
20,0.1,22.59,5.00,41.50,0.04
20,0.2,3.25,0.00,14.50,0.04
20,0.3,0.56,0.00,5.00,0.04
20,0.4,1.46,0.00,3.25,0.07
20,0.5,36.75,1.25,99.00,0.26
20,0.6,99.80,99.00,100.00,0.23
20,0.7,99.95,99.00,100.00,0.13
20,0.8,100.00,100.00,100.00,0.09
20,0.9,100.00,100.00,100.00,0.05
25,0.1,16.00,7.84,32.80,0.06
25,0.2,0.00,0.00,0.00,0.04
25,0.3,0.05,0.00,0.64,0.06
25,0.4,0.89,0.00,9.44,0.12
25,0.5,19.13,1.12,96.48,0.35
25,0.6,99.25,94.88,100.00,0.50
25,0.7,99.90,99.36,100.00,0.24
25,0.8,100.00,100.00,100.00,0.15
25,0.9,99.97,99.36,100.00,0.08
30,0.1,7.99,0.00,16.00,0.08
30,0.2,0.17,0.00,3.33,0.07
30,0.3,0.00,0.00,0.00,0.08
30,0.4,0.38,0.00,4.11,0.18
30,0.5,5.42,0.78,21.44,0.41
30,0.6,99.42,94.78,100.00,0.94
30,0.7,99.93,99.56,100.00,0.42
30,0.8,100.00,100.00,100.00,0.22
30,0.9,100.00,100.00,100.00,0.13
35,0.1,5.65,0.00,13.80,0.11
35,0.2,0.14,0.00,2.86,0.10
35,0.3,0.00,0.00,0.00,0.13
35,0.4,0.13,0.00,0.57,0.22
35,0.5,4.42,0.41,23.18,0.56
35,0.6,91.11,8.41,100.00,1.58
35,0.7,99.97,99.67,100.00,0.60
35,0.8,100.00,100.00,100.00,0.33
35,0.9,100.00,100.00,100.00,0.17
40,0.1,2.73,0.00,9.81,0.13
40,0.2,0.00,0.00,0.00,0.13
40,0.3,0.00,0.00,0.00,0.18
40,0.4,0.03,0.00,0.31,0.27
40,0.5,2.14,0.00,9.56,0.69
40,0.6,91.44,22.31,100.00,2.92
40,0.7,99.99,99.75,100.00,0.84
40,0.8,100.00,100.00,100.00,0.43
40,0.9,100.00,100.00,100.00,0.24
45,0.1,1.78,0.00,4.69,0.17
45,0.2,0.00,0.00,0.00,0.17
45,0.3,0.00,0.00,0.00,0.24
45,0.4,0.01,0.00,0.15,0.33
45,0.5,1.44,0.40,5.14,0.96
45,0.6,81.72,6.02,100.00,5.28
45,0.7,99.94,99.41,100.00,1.28
45,0.8,100.00,100.00,100.00,0.64
45,0.9,100.00,100.00,100.00,0.30
50,0.1,1.70,0.00,5.92,0.20
50,0.2,0.00,0.00,0.00,0.23
50,0.3,0.00,0.00,0.00,0.30
50,0.4,0.01,0.00,0.16,0.38
50,0.5,0.51,0.00,1.80,0.90
50,0.6,73.26,5.60,100.00,7.94
50,0.7,99.96,99.76,100.00,1.63
50,0.8,100.00,100.00,100.00,0.83
50,0.9,100.00,100.00,100.00,0.41
60,0.1,0.17,0.00,1.67,0.24
60,0.2,0.00,0.00,0.00,0.35
60,0.3,0.00,0.00,0.00,0.50
60,0.4,0.00,0.00,0.06,0.64
60,0.5,0.23,0.00,1.67,1.24
60,0.6,35.02,1.33,100.00,10.76
60,0.7,99.97,99.83,100.00,2.96
60,0.8,100.00,100.00,100.00,1.27
60,0.9,100.00,100.00,100.00,0.62
70,0.1,0.14,0.00,1.43,0.34
70,0.2,0.00,0.00,0.00,0.54
70,0.3,0.00,0.00,0.00,0.76
70,0.4,0.00,0.00,0.00,0.96
70,0.5,0.04,0.00,0.29,1.49
70,0.6,16.40,1.00,99.71,22.21
70,0.7,99.97,99.73,100.00,4.92
70,0.8,100.00,99.92,100.00,2.03
70,0.9,100.00,100.00,100.00,0.89
80,0.1,0.00,0.00,0.00,0.44
80,0.2,0.00,0.00,0.00,0.78
80,0.3,0.00,0.00,0.00,1.07
80,0.4,0.00,0.00,0.00,1.34
80,0.5,0.01,0.00,0.08,1.97
80,0.6,1.21,0.41,2.30,3.92
80,0.7,99.98,99.94,100.00,7.79
80,0.8,100.00,100.00,100.00,3.14
80,0.9,100.00,100.00,100.00,1.31
1 size density avg_solved_percent min_solved_percent max_solved_percent avg_time_ms
2 5 0.1 89.40 36.00 100.00 0.03
3 5 0.2 74.20 8.00 100.00 0.04
4 5 0.3 74.20 0.00 100.00 0.04
5 5 0.4 80.80 8.00 100.00 0.03
6 5 0.5 96.80 68.00 100.00 0.03
7 5 0.6 97.60 84.00 100.00 0.03
8 5 0.7 99.20 84.00 100.00 0.03
9 5 0.8 100.00 100.00 100.00 0.02
10 5 0.9 100.00 100.00 100.00 0.02
11 10 0.1 56.60 19.00 86.00 0.04
12 10 0.2 19.80 0.00 51.00 0.05
13 10 0.3 15.75 0.00 73.00 0.07
14 10 0.4 54.05 0.00 100.00 0.13
15 10 0.5 91.80 59.00 100.00 0.17
16 10 0.6 99.80 96.00 100.00 0.07
17 10 0.7 99.80 96.00 100.00 0.05
18 10 0.8 100.00 100.00 100.00 0.04
19 10 0.9 100.00 100.00 100.00 0.02
20 15 0.1 37.04 13.33 61.78 0.05
21 15 0.2 9.78 0.00 26.67 0.03
22 15 0.3 1.89 0.00 8.00 0.03
23 15 0.4 11.82 0.00 61.33 0.08
24 15 0.5 68.20 2.67 100.00 0.14
25 15 0.6 99.56 96.44 100.00 0.09
26 15 0.7 99.78 97.33 100.00 0.07
27 15 0.8 100.00 100.00 100.00 0.05
28 15 0.9 100.00 100.00 100.00 0.03
29 20 0.1 22.59 5.00 41.50 0.04
30 20 0.2 3.25 0.00 14.50 0.04
31 20 0.3 0.56 0.00 5.00 0.04
32 20 0.4 1.46 0.00 3.25 0.07
33 20 0.5 36.75 1.25 99.00 0.26
34 20 0.6 99.80 99.00 100.00 0.23
35 20 0.7 99.95 99.00 100.00 0.13
36 20 0.8 100.00 100.00 100.00 0.09
37 20 0.9 100.00 100.00 100.00 0.05
38 25 0.1 16.00 7.84 32.80 0.06
39 25 0.2 0.00 0.00 0.00 0.04
40 25 0.3 0.05 0.00 0.64 0.06
41 25 0.4 0.89 0.00 9.44 0.12
42 25 0.5 19.13 1.12 96.48 0.35
43 25 0.6 99.25 94.88 100.00 0.50
44 25 0.7 99.90 99.36 100.00 0.24
45 25 0.8 100.00 100.00 100.00 0.15
46 25 0.9 99.97 99.36 100.00 0.08
47 30 0.1 7.99 0.00 16.00 0.08
48 30 0.2 0.17 0.00 3.33 0.07
49 30 0.3 0.00 0.00 0.00 0.08
50 30 0.4 0.38 0.00 4.11 0.18
51 30 0.5 5.42 0.78 21.44 0.41
52 30 0.6 99.42 94.78 100.00 0.94
53 30 0.7 99.93 99.56 100.00 0.42
54 30 0.8 100.00 100.00 100.00 0.22
55 30 0.9 100.00 100.00 100.00 0.13
56 35 0.1 5.65 0.00 13.80 0.11
57 35 0.2 0.14 0.00 2.86 0.10
58 35 0.3 0.00 0.00 0.00 0.13
59 35 0.4 0.13 0.00 0.57 0.22
60 35 0.5 4.42 0.41 23.18 0.56
61 35 0.6 91.11 8.41 100.00 1.58
62 35 0.7 99.97 99.67 100.00 0.60
63 35 0.8 100.00 100.00 100.00 0.33
64 35 0.9 100.00 100.00 100.00 0.17
65 40 0.1 2.73 0.00 9.81 0.13
66 40 0.2 0.00 0.00 0.00 0.13
67 40 0.3 0.00 0.00 0.00 0.18
68 40 0.4 0.03 0.00 0.31 0.27
69 40 0.5 2.14 0.00 9.56 0.69
70 40 0.6 91.44 22.31 100.00 2.92
71 40 0.7 99.99 99.75 100.00 0.84
72 40 0.8 100.00 100.00 100.00 0.43
73 40 0.9 100.00 100.00 100.00 0.24
74 45 0.1 1.78 0.00 4.69 0.17
75 45 0.2 0.00 0.00 0.00 0.17
76 45 0.3 0.00 0.00 0.00 0.24
77 45 0.4 0.01 0.00 0.15 0.33
78 45 0.5 1.44 0.40 5.14 0.96
79 45 0.6 81.72 6.02 100.00 5.28
80 45 0.7 99.94 99.41 100.00 1.28
81 45 0.8 100.00 100.00 100.00 0.64
82 45 0.9 100.00 100.00 100.00 0.30
83 50 0.1 1.70 0.00 5.92 0.20
84 50 0.2 0.00 0.00 0.00 0.23
85 50 0.3 0.00 0.00 0.00 0.30
86 50 0.4 0.01 0.00 0.16 0.38
87 50 0.5 0.51 0.00 1.80 0.90
88 50 0.6 73.26 5.60 100.00 7.94
89 50 0.7 99.96 99.76 100.00 1.63
90 50 0.8 100.00 100.00 100.00 0.83
91 50 0.9 100.00 100.00 100.00 0.41
92 60 0.1 0.17 0.00 1.67 0.24
93 60 0.2 0.00 0.00 0.00 0.35
94 60 0.3 0.00 0.00 0.00 0.50
95 60 0.4 0.00 0.00 0.06 0.64
96 60 0.5 0.23 0.00 1.67 1.24
97 60 0.6 35.02 1.33 100.00 10.76
98 60 0.7 99.97 99.83 100.00 2.96
99 60 0.8 100.00 100.00 100.00 1.27
100 60 0.9 100.00 100.00 100.00 0.62
101 70 0.1 0.14 0.00 1.43 0.34
102 70 0.2 0.00 0.00 0.00 0.54
103 70 0.3 0.00 0.00 0.00 0.76
104 70 0.4 0.00 0.00 0.00 0.96
105 70 0.5 0.04 0.00 0.29 1.49
106 70 0.6 16.40 1.00 99.71 22.21
107 70 0.7 99.97 99.73 100.00 4.92
108 70 0.8 100.00 99.92 100.00 2.03
109 70 0.9 100.00 100.00 100.00 0.89
110 80 0.1 0.00 0.00 0.00 0.44
111 80 0.2 0.00 0.00 0.00 0.78
112 80 0.3 0.00 0.00 0.00 1.07
113 80 0.4 0.00 0.00 0.00 1.34
114 80 0.5 0.01 0.00 0.08 1.97
115 80 0.6 1.21 0.41 2.30 3.92
116 80 0.7 99.98 99.94 100.00 7.79
117 80 0.8 100.00 100.00 100.00 3.14
118 80 0.9 100.00 100.00 100.00 1.31

View File

@@ -0,0 +1,938 @@
[
{
"size": 5,
"density": 0.1,
"avgSolved": 89.4,
"minSolved": 36,
"maxSolved": 100,
"avgTime": 0.031666799999999905
},
{
"size": 5,
"density": 0.2,
"avgSolved": 74.2,
"minSolved": 8,
"maxSolved": 100,
"avgTime": 0.03671869999999924
},
{
"size": 5,
"density": 0.3,
"avgSolved": 74.2,
"minSolved": 0,
"maxSolved": 100,
"avgTime": 0.04439559999999983
},
{
"size": 5,
"density": 0.4,
"avgSolved": 80.8,
"minSolved": 8,
"maxSolved": 100,
"avgTime": 0.0317166499999999
},
{
"size": 5,
"density": 0.5,
"avgSolved": 96.8,
"minSolved": 68,
"maxSolved": 100,
"avgTime": 0.0309604000000002
},
{
"size": 5,
"density": 0.6,
"avgSolved": 97.6,
"minSolved": 84,
"maxSolved": 100,
"avgTime": 0.031464499999999875
},
{
"size": 5,
"density": 0.7,
"avgSolved": 99.2,
"minSolved": 84,
"maxSolved": 100,
"avgTime": 0.03086874999999978
},
{
"size": 5,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.01615615000000048
},
{
"size": 5,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.02271474999999956
},
{
"size": 10,
"density": 0.1,
"avgSolved": 56.6,
"minSolved": 19,
"maxSolved": 86,
"avgTime": 0.042958299999999915
},
{
"size": 10,
"density": 0.2,
"avgSolved": 19.8,
"minSolved": 0,
"maxSolved": 51,
"avgTime": 0.050141749999999874
},
{
"size": 10,
"density": 0.3,
"avgSolved": 15.75,
"minSolved": 0,
"maxSolved": 73,
"avgTime": 0.06852290000000014
},
{
"size": 10,
"density": 0.4,
"avgSolved": 54.05,
"minSolved": 0,
"maxSolved": 100,
"avgTime": 0.12701870000000018
},
{
"size": 10,
"density": 0.5,
"avgSolved": 91.8,
"minSolved": 59,
"maxSolved": 100,
"avgTime": 0.16561034999999985
},
{
"size": 10,
"density": 0.6,
"avgSolved": 99.8,
"minSolved": 96,
"maxSolved": 100,
"avgTime": 0.07136649999999882
},
{
"size": 10,
"density": 0.7,
"avgSolved": 99.8,
"minSolved": 96,
"maxSolved": 100,
"avgTime": 0.04808134999999893
},
{
"size": 10,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.03795824999999979
},
{
"size": 10,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.024952100000000855
},
{
"size": 15,
"density": 0.1,
"avgSolved": 37.044444444444444,
"minSolved": 13.333333333333334,
"maxSolved": 61.77777777777778,
"avgTime": 0.045045850000000345
},
{
"size": 15,
"density": 0.2,
"avgSolved": 9.777777777777775,
"minSolved": 0,
"maxSolved": 26.666666666666668,
"avgTime": 0.034581349999998776
},
{
"size": 15,
"density": 0.3,
"avgSolved": 1.8888888888888886,
"minSolved": 0,
"maxSolved": 8,
"avgTime": 0.029402199999999823
},
{
"size": 15,
"density": 0.4,
"avgSolved": 11.822222222222223,
"minSolved": 0,
"maxSolved": 61.33333333333333,
"avgTime": 0.07898965000000047
},
{
"size": 15,
"density": 0.5,
"avgSolved": 68.19999999999999,
"minSolved": 2.666666666666667,
"maxSolved": 100,
"avgTime": 0.1374602999999997
},
{
"size": 15,
"density": 0.6,
"avgSolved": 99.55555555555554,
"minSolved": 96.44444444444444,
"maxSolved": 100,
"avgTime": 0.09379159999999978
},
{
"size": 15,
"density": 0.7,
"avgSolved": 99.77777777777779,
"minSolved": 97.33333333333334,
"maxSolved": 100,
"avgTime": 0.07072704999999928
},
{
"size": 15,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.050104250000000405
},
{
"size": 15,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.031362550000000766
},
{
"size": 20,
"density": 0.1,
"avgSolved": 22.5875,
"minSolved": 5,
"maxSolved": 41.5,
"avgTime": 0.04363335000000035
},
{
"size": 20,
"density": 0.2,
"avgSolved": 3.25,
"minSolved": 0,
"maxSolved": 14.499999999999998,
"avgTime": 0.03823525000000103
},
{
"size": 20,
"density": 0.3,
"avgSolved": 0.5625,
"minSolved": 0,
"maxSolved": 5,
"avgTime": 0.03880414999999786
},
{
"size": 20,
"density": 0.4,
"avgSolved": 1.4625,
"minSolved": 0,
"maxSolved": 3.25,
"avgTime": 0.06692695000000129
},
{
"size": 20,
"density": 0.5,
"avgSolved": 36.75,
"minSolved": 1.25,
"maxSolved": 99,
"avgTime": 0.25872084999999884
},
{
"size": 20,
"density": 0.6,
"avgSolved": 99.8,
"minSolved": 99,
"maxSolved": 100,
"avgTime": 0.2258772000000004
},
{
"size": 20,
"density": 0.7,
"avgSolved": 99.95,
"minSolved": 99,
"maxSolved": 100,
"avgTime": 0.13418124999999997
},
{
"size": 20,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.09264785000000053
},
{
"size": 20,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.05307699999999756
},
{
"size": 25,
"density": 0.1,
"avgSolved": 16.000000000000004,
"minSolved": 7.84,
"maxSolved": 32.800000000000004,
"avgTime": 0.05678540000000112
},
{
"size": 25,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.04278334999999842
},
{
"size": 25,
"density": 0.3,
"avgSolved": 0.048,
"minSolved": 0,
"maxSolved": 0.64,
"avgTime": 0.05884794999999983
},
{
"size": 25,
"density": 0.4,
"avgSolved": 0.8880000000000001,
"minSolved": 0,
"maxSolved": 9.44,
"avgTime": 0.11761245000000287
},
{
"size": 25,
"density": 0.5,
"avgSolved": 19.128000000000007,
"minSolved": 1.1199999999999999,
"maxSolved": 96.48,
"avgTime": 0.3490229000000021
},
{
"size": 25,
"density": 0.6,
"avgSolved": 99.24799999999998,
"minSolved": 94.88,
"maxSolved": 100,
"avgTime": 0.49611459999999996
},
{
"size": 25,
"density": 0.7,
"avgSolved": 99.904,
"minSolved": 99.36,
"maxSolved": 100,
"avgTime": 0.23916465000000073
},
{
"size": 25,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.14604994999999973
},
{
"size": 25,
"density": 0.9,
"avgSolved": 99.96799999999999,
"minSolved": 99.36,
"maxSolved": 100,
"avgTime": 0.08385419999999896
},
{
"size": 30,
"density": 0.1,
"avgSolved": 7.988888888888889,
"minSolved": 0,
"maxSolved": 16,
"avgTime": 0.08026245000000073
},
{
"size": 30,
"density": 0.2,
"avgSolved": 0.16666666666666669,
"minSolved": 0,
"maxSolved": 3.3333333333333335,
"avgTime": 0.06999999999999887
},
{
"size": 30,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.08285835000000005
},
{
"size": 30,
"density": 0.4,
"avgSolved": 0.3777777777777777,
"minSolved": 0,
"maxSolved": 4.111111111111112,
"avgTime": 0.1756041499999988
},
{
"size": 30,
"density": 0.5,
"avgSolved": 5.4222222222222225,
"minSolved": 0.7777777777777778,
"maxSolved": 21.444444444444443,
"avgTime": 0.41105620000000015
},
{
"size": 30,
"density": 0.6,
"avgSolved": 99.41666666666669,
"minSolved": 94.77777777777779,
"maxSolved": 100,
"avgTime": 0.9417500999999995
},
{
"size": 30,
"density": 0.7,
"avgSolved": 99.93333333333335,
"minSolved": 99.55555555555556,
"maxSolved": 100,
"avgTime": 0.41628955000000334
},
{
"size": 30,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.22320620000000133
},
{
"size": 30,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.13331460000000134
},
{
"size": 35,
"density": 0.1,
"avgSolved": 5.653061224489796,
"minSolved": 0,
"maxSolved": 13.795918367346937,
"avgTime": 0.11177699999999931
},
{
"size": 35,
"density": 0.2,
"avgSolved": 0.14285714285714285,
"minSolved": 0,
"maxSolved": 2.857142857142857,
"avgTime": 0.09598544999999917
},
{
"size": 35,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.1290145000000038
},
{
"size": 35,
"density": 0.4,
"avgSolved": 0.1346938775510204,
"minSolved": 0,
"maxSolved": 0.5714285714285714,
"avgTime": 0.21904799999999797
},
{
"size": 35,
"density": 0.5,
"avgSolved": 4.424489795918367,
"minSolved": 0.40816326530612246,
"maxSolved": 23.183673469387756,
"avgTime": 0.5596769500000022
},
{
"size": 35,
"density": 0.6,
"avgSolved": 91.1061224489796,
"minSolved": 8.408163265306122,
"maxSolved": 100,
"avgTime": 1.5827311000000024
},
{
"size": 35,
"density": 0.7,
"avgSolved": 99.9673469387755,
"minSolved": 99.67346938775509,
"maxSolved": 100,
"avgTime": 0.5970167499999988
},
{
"size": 35,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.33084175000000327
},
{
"size": 35,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.1685022000000032
},
{
"size": 40,
"density": 0.1,
"avgSolved": 2.734375,
"minSolved": 0,
"maxSolved": 9.8125,
"avgTime": 0.13156869999999826
},
{
"size": 40,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.13052910000000112
},
{
"size": 40,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.17675199999999905
},
{
"size": 40,
"density": 0.4,
"avgSolved": 0.03125,
"minSolved": 0,
"maxSolved": 0.3125,
"avgTime": 0.26616039999999686
},
{
"size": 40,
"density": 0.5,
"avgSolved": 2.14375,
"minSolved": 0,
"maxSolved": 9.5625,
"avgTime": 0.694316649999999
},
{
"size": 40,
"density": 0.6,
"avgSolved": 91.44375,
"minSolved": 22.3125,
"maxSolved": 100,
"avgTime": 2.9244042000000006
},
{
"size": 40,
"density": 0.7,
"avgSolved": 99.9875,
"minSolved": 99.75,
"maxSolved": 100,
"avgTime": 0.8381519999999967
},
{
"size": 40,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.4339062999999925
},
{
"size": 40,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.2375938000000076
},
{
"size": 45,
"density": 0.1,
"avgSolved": 1.7827160493827159,
"minSolved": 0,
"maxSolved": 4.691358024691358,
"avgTime": 0.1660813500000046
},
{
"size": 45,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.1715666999999968
},
{
"size": 45,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.23760415000000706
},
{
"size": 45,
"density": 0.4,
"avgSolved": 0.012345679012345678,
"minSolved": 0,
"maxSolved": 0.14814814814814814,
"avgTime": 0.333931249999992
},
{
"size": 45,
"density": 0.5,
"avgSolved": 1.439506172839506,
"minSolved": 0.39506172839506176,
"maxSolved": 5.135802469135802,
"avgTime": 0.9644125499999916
},
{
"size": 45,
"density": 0.6,
"avgSolved": 81.71851851851852,
"minSolved": 6.0246913580246915,
"maxSolved": 100,
"avgTime": 5.281324949999998
},
{
"size": 45,
"density": 0.7,
"avgSolved": 99.94074074074075,
"minSolved": 99.4074074074074,
"maxSolved": 100,
"avgTime": 1.2768960000000107
},
{
"size": 45,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.638566650000007
},
{
"size": 45,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.3004915999999923
},
{
"size": 50,
"density": 0.1,
"avgSolved": 1.7,
"minSolved": 0,
"maxSolved": 5.92,
"avgTime": 0.20294785000000387
},
{
"size": 50,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.23199789999999892
},
{
"size": 50,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.29876259999999205
},
{
"size": 50,
"density": 0.4,
"avgSolved": 0.008,
"minSolved": 0,
"maxSolved": 0.16,
"avgTime": 0.38459799999998834
},
{
"size": 50,
"density": 0.5,
"avgSolved": 0.5099999999999999,
"minSolved": 0,
"maxSolved": 1.7999999999999998,
"avgTime": 0.8961771499999941
},
{
"size": 50,
"density": 0.6,
"avgSolved": 73.258,
"minSolved": 5.6000000000000005,
"maxSolved": 100,
"avgTime": 7.937735449999991
},
{
"size": 50,
"density": 0.7,
"avgSolved": 99.96399999999998,
"minSolved": 99.76,
"maxSolved": 100,
"avgTime": 1.6324250000000062
},
{
"size": 50,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.8293270000000064
},
{
"size": 50,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.41459575000000654
},
{
"size": 60,
"density": 0.1,
"avgSolved": 0.16666666666666669,
"minSolved": 0,
"maxSolved": 1.6666666666666667,
"avgTime": 0.2432124999999928
},
{
"size": 60,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.35082704999999237
},
{
"size": 60,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.49827310000000014
},
{
"size": 60,
"density": 0.4,
"avgSolved": 0.0027777777777777775,
"minSolved": 0,
"maxSolved": 0.05555555555555555,
"avgTime": 0.6393062499999985
},
{
"size": 60,
"density": 0.5,
"avgSolved": 0.23055555555555554,
"minSolved": 0,
"maxSolved": 1.6666666666666667,
"avgTime": 1.2402395500000012
},
{
"size": 60,
"density": 0.6,
"avgSolved": 35.01805555555556,
"minSolved": 1.3333333333333335,
"maxSolved": 100,
"avgTime": 10.759754149999992
},
{
"size": 60,
"density": 0.7,
"avgSolved": 99.96944444444445,
"minSolved": 99.83333333333333,
"maxSolved": 100,
"avgTime": 2.964204100000029
},
{
"size": 60,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 1.2736664999999903
},
{
"size": 60,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.6249353999999812
},
{
"size": 70,
"density": 0.1,
"avgSolved": 0.14285714285714285,
"minSolved": 0,
"maxSolved": 1.4285714285714286,
"avgTime": 0.34277719999998907
},
{
"size": 70,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.5435105000000249
},
{
"size": 70,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.7600602999999865
},
{
"size": 70,
"density": 0.4,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.9591250999999943
},
{
"size": 70,
"density": 0.5,
"avgSolved": 0.04081632653061225,
"minSolved": 0,
"maxSolved": 0.2857142857142857,
"avgTime": 1.491010399999982
},
{
"size": 70,
"density": 0.6,
"avgSolved": 16.403061224489797,
"minSolved": 1,
"maxSolved": 99.71428571428571,
"avgTime": 22.21432699999999
},
{
"size": 70,
"density": 0.7,
"avgSolved": 99.96836734693878,
"minSolved": 99.73469387755102,
"maxSolved": 100,
"avgTime": 4.92020829999999
},
{
"size": 70,
"density": 0.8,
"avgSolved": 99.99591836734695,
"minSolved": 99.91836734693878,
"maxSolved": 100,
"avgTime": 2.0306394499999554
},
{
"size": 70,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.8882499500000336
},
{
"size": 80,
"density": 0.1,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.4418666499999858
},
{
"size": 80,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.7795667999999978
},
{
"size": 80,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 1.0745101999999747
},
{
"size": 80,
"density": 0.4,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 1.3407041500000105
},
{
"size": 80,
"density": 0.5,
"avgSolved": 0.0125,
"minSolved": 0,
"maxSolved": 0.078125,
"avgTime": 1.9724897000000283
},
{
"size": 80,
"density": 0.6,
"avgSolved": 1.21484375,
"minSolved": 0.40625,
"maxSolved": 2.296875,
"avgTime": 3.9163123999999927
},
{
"size": 80,
"density": 0.7,
"avgSolved": 99.978125,
"minSolved": 99.9375,
"maxSolved": 100,
"avgTime": 7.790070799999967
},
{
"size": 80,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 3.1350061999999754
},
{
"size": 80,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 1.3134414999999535
}
]

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.6.3", "version": "1.9.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.6.3", "version": "1.9.5",
"dependencies": { "dependencies": {
"fireworks-js": "^2.10.8", "fireworks-js": "^2.10.8",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.6.3", "version": "1.9.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/composables/useI18n.js');
let content = fs.readFileSync(filePath, 'utf8');
const newKeys = {
'custom.simulationHelp': 'How is this calculated?',
'custom.hideMap': 'Hide difficulty map',
'custom.showMap': 'Show difficulty map',
'simulation.title': 'Difficulty Simulation',
'simulation.status.ready': 'Ready',
'simulation.status.stopped': 'Stopped',
'simulation.status.completed': 'Completed',
'simulation.status.simulating': 'Simulating {size}x{size} @ {density}%',
'simulation.start': 'Start Simulation',
'simulation.stop': 'Stop',
'simulation.table.size': 'Size',
'simulation.table.density': 'Density',
'simulation.table.solved': 'Solved (Logic)',
'simulation.empty': 'Press Start to run Monte Carlo simulation'
};
const lines = content.split('\n');
const processedLines = [];
let currentLang = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Detect start of language block
const startMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
if (startMatch) {
currentLang = startMatch[1].replace(/['"]/g, '');
}
// Detect end of language block
if (currentLang && (line.trim() === '},' || line.trim() === '}')) {
if (currentLang !== 'pl' && currentLang !== 'en') {
// Ensure previous line has comma
if (processedLines.length > 0) {
let lastLine = processedLines[processedLines.length - 1];
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
processedLines[processedLines.length - 1] = lastLine + ',';
}
}
// Append new keys
Object.entries(newKeys).forEach(([key, value]) => {
processedLines.push(` '${key}': '${value}',`);
});
}
currentLang = null;
}
processedLines.push(line);
}
const finalContent = processedLines.join('\n');
fs.writeFileSync(filePath, finalContent);
console.log('Successfully added simulation translations to all languages.');

View File

@@ -0,0 +1,97 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/composables/useI18n.js');
let content = fs.readFileSync(filePath, 'utf8');
const newKeys = {
'custom.simulationHelp': 'How is this calculated?',
'simulation.title': 'Difficulty Simulation',
'simulation.status.ready': 'Ready',
'simulation.status.stopped': 'Stopped',
'simulation.status.completed': 'Completed',
'simulation.status.simulating': 'Simulating {size}x{size} @ {density}%',
'simulation.start': 'Start Simulation',
'simulation.stop': 'Stop',
'simulation.table.size': 'Size',
'simulation.table.density': 'Density',
'simulation.table.solved': 'Solved (Logic)',
'simulation.empty': 'Press Start to run Monte Carlo simulation'
};
// Regex to match the end of a language block
// Matches " }," or " }" at the start of a line
const blockEndRegex = /^(\s{2})\},?$/gm;
let newContent = content.replace(blockEndRegex, (match, indent, offset) => {
// Determine which language we are closing
const precedingText = content.substring(0, offset);
const langMatch = precedingText.match(/^\s{2}(\w+([-]\w+)?): \{/gm);
if (!langMatch) return match;
const currentLangLine = langMatch[langMatch.length - 1];
const currentLang = currentLangLine.match(/^\s{2}(\w+([-]\w+)?): \{/)[1];
// Skip pl and en as they are already updated
if (currentLang === 'pl' || currentLang === 'en') {
return match;
}
// Check if the previous line has a comma
// We need to look at the lines before the match
// This is tricky with replace callback.
// Easier strategy: Just insert the keys.
// If the file is well formatted, the last item might or might not have a comma.
// But we can ensure *our* inserted block starts with a comma if needed?
// No, standard JS objects need comma after previous item.
// Let's assume we simply inject before the closing brace.
// We'll add a comma to the previous line if it doesn't have one?
// That's hard with regex replace on the closing brace only.
// Alternative: Split by lines and process.
return match; // Placeholder, we will process by lines below.
});
const lines = content.split('\n');
const processedLines = [];
let currentLang = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Detect start of language block
const startMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
if (startMatch) {
currentLang = startMatch[1].replace(/['"]/g, '');
}
// Detect end of language block
if (currentLang && (line.trim() === '},' || line.trim() === '}')) {
if (currentLang !== 'pl' && currentLang !== 'en') {
// Ensure previous line has comma
if (processedLines.length > 0) {
let lastLine = processedLines[processedLines.length - 1];
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
processedLines[processedLines.length - 1] = lastLine + ',';
}
}
// Append new keys
Object.entries(newKeys).forEach(([key, value]) => {
processedLines.push(` '${key}': '${value}',`);
});
// Remove trailing comma from last inserted item if we want strictly JSON-like (but JS allows it)
// It's fine to leave it.
}
currentLang = null;
}
processedLines.push(line);
}
const finalContent = processedLines.join('\n');
fs.writeFileSync(filePath, finalContent);
console.log('Successfully added simulation translations to all languages.');

View File

@@ -0,0 +1,75 @@
import fs from 'fs';
import path from 'path';
import { generateRandomGrid, calculateHints } from '../src/utils/puzzleUtils.js';
import { solvePuzzle } from '../src/utils/solver.js';
const OUTPUT_FILE = 'difficulty_simulation_results.json';
const CSV_FILE = 'difficulty_simulation_results.csv';
// Configuration
const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80]; // Steps of 5 up to 50, then 10
const DENSITIES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
const SAMPLES_PER_POINT = 20; // Adjust based on time/accuracy needs
console.log('Starting Monte Carlo Simulation for Nonogram Difficulty...');
console.log(`Config: Sizes=${SIZES.length}, Densities=${DENSITIES.length}, Samples=${SAMPLES_PER_POINT}`);
const results = [];
const csvRows = ['size,density,avg_solved_percent,min_solved_percent,max_solved_percent,avg_time_ms'];
const startTime = Date.now();
for (const size of SIZES) {
for (const density of DENSITIES) {
let totalSolved = 0;
let minSolved = 100;
let maxSolved = 0;
let totalTime = 0;
process.stdout.write(`Simulating Size: ${size}x${size}, Density: ${density} ... `);
for (let i = 0; i < SAMPLES_PER_POINT; i++) {
const t0 = performance.now();
// 1. Generate
const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid);
// 2. Solve
const { percentSolved } = solvePuzzle(rowHints, colHints);
const t1 = performance.now();
totalSolved += percentSolved;
minSolved = Math.min(minSolved, percentSolved);
maxSolved = Math.max(maxSolved, percentSolved);
totalTime += (t1 - t0);
}
const avgSolved = totalSolved / SAMPLES_PER_POINT;
const avgTime = totalTime / SAMPLES_PER_POINT;
results.push({
size,
density,
avgSolved,
minSolved,
maxSolved,
avgTime
});
csvRows.push(`${size},${density},${avgSolved.toFixed(2)},${minSolved.toFixed(2)},${maxSolved.toFixed(2)},${avgTime.toFixed(2)}`);
console.log(`Avg Solved: ${avgSolved.toFixed(1)}%`);
}
}
const totalDuration = (Date.now() - startTime) / 1000;
console.log(`Simulation complete in ${totalDuration.toFixed(1)}s`);
// Save results
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2));
fs.writeFileSync(CSV_FILE, csvRows.join('\n'));
console.log(`Results saved to ${OUTPUT_FILE} and ${CSV_FILE}`);

View File

@@ -8,6 +8,7 @@ import StatusPanel from './components/StatusPanel.vue';
import GuidePanel from './components/GuidePanel.vue'; import GuidePanel from './components/GuidePanel.vue';
import WinModal from './components/WinModal.vue'; import WinModal from './components/WinModal.vue';
import CustomGameModal from './components/CustomGameModal.vue'; import CustomGameModal from './components/CustomGameModal.vue';
import SimulationView from './components/SimulationView.vue';
import FixedBar from './components/FixedBar.vue'; import FixedBar from './components/FixedBar.vue';
import ReloadPrompt from './components/ReloadPrompt.vue'; import ReloadPrompt from './components/ReloadPrompt.vue';
@@ -15,6 +16,7 @@ import ReloadPrompt from './components/ReloadPrompt.vue';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n(); const { t, locale, setLocale, locales } = useI18n();
const showCustomModal = ref(false); const showCustomModal = ref(false);
const showSimulation = ref(false);
const showGuide = ref(false); const showGuide = ref(false);
const deferredPrompt = ref(null); const deferredPrompt = ref(null);
const canInstall = ref(false); const canInstall = ref(false);
@@ -173,7 +175,8 @@ onUnmounted(() => {
<!-- Modals Teleport --> <!-- Modals Teleport -->
<Teleport to="body"> <Teleport to="body">
<WinModal v-if="store.isGameWon" /> <WinModal v-if="store.isGameWon" />
<CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" /> <CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" @open-simulation="showSimulation = true" />
<SimulationView v-if="showSimulation" @close="showSimulation = false" />
<ReloadPrompt /> <ReloadPrompt />
</Teleport> </Teleport>
</main> </main>
@@ -183,10 +186,11 @@ onUnmounted(() => {
.game-container { .game-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch; /* was center */
gap: 20px; gap: 20px;
width: 100%; width: 100%;
padding-bottom: 50px; padding-bottom: 50px;
padding-top: 100px; /* Space for fixed NavBar */
} }
.install-banner { .install-banner {
@@ -199,8 +203,11 @@ onUnmounted(() => {
align-items: center; align-items: center;
width: 90%; width: 90%;
max-width: 600px; max-width: 600px;
margin-bottom: 20px; margin: 0 auto 20px auto; /* Center it manually */
box-shadow: var(--banner-shadow); box-shadow: var(--banner-shadow);
position: sticky;
left: 0;
right: 0;
} }
.install-text { .install-text {
@@ -224,17 +231,27 @@ onUnmounted(() => {
.game-layout { .game-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
gap: 20px; gap: 20px;
width: 100%; width: 100%;
max-width: 1200px;
padding: 0 20px; padding: 0 20px;
} }
/* Center children (except board section which handles itself) */
.game-layout > *:not(.board-section) {
margin-left: auto;
margin-right: auto;
max-width: 1200px; /* Keep constraint for panels */
width: 100%;
position: sticky;
left: 0;
right: 0;
}
.board-section { .board-section {
display: flex; display: block;
justify-content: center;
width: 100%; width: 100%;
overflow-x: visible;
} }
.fade-enter-active, .fade-enter-active,

View File

@@ -1,16 +1,173 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { calculateDifficulty } from '@/utils/puzzleUtils'; import { calculateDifficulty } from '@/utils/puzzleUtils';
import { HelpCircle } from 'lucide-vue-next';
const emit = defineEmits(['close']); const emit = defineEmits(['close', 'open-simulation']);
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t } = useI18n(); const { t } = useI18n();
const customSize = ref(10); const customSize = ref(10);
const fillRate = ref(50); const fillRate = ref(50);
const errorMsg = ref(''); const errorMsg = ref('');
const difficultyCanvas = ref(null);
const isDragging = ref(false);
const cachedBackground = ref(null);
const drawMap = () => {
const canvas = difficultyCanvas.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Clear
ctx.clearRect(0, 0, width, height);
// Use cached background if available
if (cachedBackground.value) {
ctx.putImageData(cachedBackground.value, 0, 0);
} else {
// Draw Gradient Background (Heavy calculation)
const imgData = ctx.createImageData(width, height);
const data = imgData.data;
// Ranges:
// X: Fill Rate 10% -> 90%
// Y: Size 5 -> 80
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const normalizedX = x / width;
const normalizedY = 1 - (y / height); // 0 at bottom, 1 at top
const fRate = 0.1 + normalizedX * 0.8; // 0.1 to 0.9
const sSize = 5 + normalizedY * 75; // 5 to 80
const { value } = calculateDifficulty(fRate, sSize);
// Color Mapping
const hue = 120 * (1 - value / 100);
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
const index = (y * width + x) * 4;
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
data[index + 3] = 255; // Alpha
}
}
ctx.putImageData(imgData, 0, 0);
cachedBackground.value = imgData;
}
// Draw current position
// Map current fillRate/size to x,y
// Fill: 10..90. Size: 5..80.
const currentFill = Math.max(10, Math.min(90, fillRate.value));
const currentSize = Math.max(5, Math.min(80, customSize.value));
const posX = ((currentFill - 10) / 80) * width;
const posY = (1 - (currentSize - 5) / 75) * height;
// Draw Crosshair/Circle
ctx.beginPath();
ctx.arc(posX, posY, 6, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#000';
ctx.stroke();
};
const hslToRgb = (h, s, l) => {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
const updateFromEvent = (e) => {
const canvas = difficultyCanvas.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
// Handle Touch or Mouse
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
let x = clientX - rect.left;
let y = clientY - rect.top;
// Clamp
x = Math.max(0, Math.min(rect.width, x));
y = Math.max(0, Math.min(rect.height, y));
// Reverse Map
// x / width -> fillRate (10..90)
// 1 - y / height -> size (5..80)
const normalizedX = x / rect.width;
const normalizedY = 1 - (y / rect.height);
const newFill = 10 + normalizedX * 80;
const newSize = 5 + normalizedY * 75;
fillRate.value = Math.round(newFill);
customSize.value = Math.round(newSize);
};
const startDrag = (e) => {
isDragging.value = true;
updateFromEvent(e);
// Add global listeners for mouse to handle dragging outside canvas
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag);
};
const onDrag = (e) => {
if (!isDragging.value) return;
updateFromEvent(e);
};
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
};
onUnmounted(() => {
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
});
const showAdvanced = ref(false);
const toggleAdvanced = () => {
showAdvanced.value = !showAdvanced.value;
if (showAdvanced.value) {
// Reset cache when opening to ensure size is correct if canvas resized
cachedBackground.value = null;
nextTick(drawMap);
}
};
onMounted(() => { onMounted(() => {
const savedSize = localStorage.getItem('nonograms_custom_size'); const savedSize = localStorage.getItem('nonograms_custom_size');
@@ -22,6 +179,14 @@ onMounted(() => {
if (savedFillRate && !isNaN(savedFillRate)) { if (savedFillRate && !isNaN(savedFillRate)) {
fillRate.value = Math.max(10, Math.min(90, Number(savedFillRate))); fillRate.value = Math.max(10, Math.min(90, Number(savedFillRate)));
} }
// Don't draw map initially if hidden
});
watch([customSize, fillRate], () => {
if (showAdvanced.value) {
drawMap();
}
}); });
watch(customSize, (newVal) => { watch(customSize, (newVal) => {
@@ -41,12 +206,12 @@ const handleSnap = () => {
customSize.value = snapToStep(Number(customSize.value), 5); customSize.value = snapToStep(Number(customSize.value), 5);
}; };
const difficultyLevel = computed(() => { const difficultyInfo = computed(() => {
return calculateDifficulty(fillRate.value / 100); return calculateDifficulty(fillRate.value / 100, customSize.value);
}); });
const difficultyColor = computed(() => { const difficultyColor = computed(() => {
switch(difficultyLevel.value) { switch(difficultyInfo.value.level) {
case 'extreme': return '#ff3333'; case 'extreme': return '#ff3333';
case 'hardest': return '#ff9933'; case 'hardest': return '#ff9933';
case 'harder': return '#ffff33'; case 'harder': return '#ffff33';
@@ -71,45 +236,77 @@ const confirm = () => {
<div class="modal-overlay" @click.self="emit('close')"> <div class="modal-overlay" @click.self="emit('close')">
<div class="modal glass-panel"> <div class="modal glass-panel">
<h2>{{ t('custom.title') }}</h2> <h2>{{ t('custom.title') }}</h2>
<p>{{ t('custom.prompt') }}</p>
<div class="input-group"> <div class="modal-content">
<div class="range-value">{{ customSize }}</div> <div class="sliders-section">
<input <div class="slider-container">
type="range" <p>{{ t('custom.prompt') }}</p>
v-model="customSize" <div class="input-group">
min="5" <div class="range-value">{{ customSize }}</div>
max="80" <input
step="1" type="range"
@change="handleSnap" v-model="customSize"
/> min="5"
<div class="range-scale"> max="80"
<span>5</span> step="1"
<span>80</span> @change="handleSnap"
</div> />
</div> <div class="range-scale">
<span>5</span>
<span>80</span>
</div>
</div>
</div>
<p>{{ t('custom.fillRate') }}</p> <div class="slider-container">
<div class="input-group"> <p>{{ t('custom.fillRate') }}</p>
<div class="range-value">{{ fillRate }}%</div> <div class="input-group">
<input <div class="range-value">{{ fillRate }}%</div>
type="range" <input
v-model="fillRate" type="range"
min="10" v-model="fillRate"
max="90" min="10"
step="5" max="90"
/> step="1"
<div class="range-scale"> />
<span>10%</span> <div class="range-scale">
<span>90%</span> <span>10%</span>
<span>90%</span>
</div>
</div>
</div>
</div>
<div class="map-section" v-if="showAdvanced">
<canvas
ref="difficultyCanvas"
width="400"
height="400"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
@touchmove.prevent="onDrag"
@touchend="stopDrag"
></canvas>
</div> </div>
</div> </div>
<div class="difficulty-indicator"> <div class="difficulty-indicator">
<span class="label">{{ t('custom.difficulty') }}:</span> <div class="label-row">
<span class="value" :style="{ color: difficultyColor }"> <div class="label">{{ t('custom.difficulty') }}</div>
{{ t(`difficulty.${difficultyLevel}`) }} <button class="help-btn" @click="emit('open-simulation')" :title="t('custom.simulationHelp') || 'How is this calculated?'">
</span> <HelpCircle class="icon-sm" />
</button>
</div>
<div class="difficulty-row">
<div class="level" :style="{ color: difficultyColor }">{{ t(`difficulty.${difficultyInfo.level}`) }}</div>
<div class="percentage" :style="{ color: difficultyColor }">({{ difficultyInfo.value }}%)</div>
</div>
</div>
<div class="advanced-toggle">
<button class="btn-text" @click="toggleAdvanced">
{{ showAdvanced ? t('custom.hideMap') : t('custom.showMap') }}
</button>
</div> </div>
<p v-if="errorMsg" class="error">{{ errorMsg }}</p> <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
@@ -141,7 +338,7 @@ const confirm = () => {
.modal { .modal {
padding: 40px; padding: 40px;
text-align: center; text-align: center;
max-width: 400px; max-width: 800px;
width: 90%; width: 90%;
border: 1px solid var(--accent-cyan); border: 1px solid var(--accent-cyan);
box-shadow: 0 0 50px rgba(0, 242, 255, 0.2); box-shadow: 0 0 50px rgba(0, 242, 255, 0.2);
@@ -149,6 +346,54 @@ const confirm = () => {
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
} }
.modal-content {
display: flex;
flex-direction: row;
gap: 40px;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
@media (max-width: 700px) {
.modal-content {
flex-direction: column;
gap: 20px;
}
}
.sliders-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.map-section {
flex: 0 0 auto;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
width: 400px;
height: 400px;
border: 2px solid var(--panel-border);
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.1);
cursor: crosshair;
background: #000;
}
@media (max-width: 600px) {
canvas {
width: 100%;
height: auto;
aspect-ratio: 1;
}
}
h2 { h2 {
font-size: 2rem; font-size: 2rem;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -162,6 +407,11 @@ p {
margin-bottom: 20px; margin-bottom: 20px;
} }
.slider-container {
width: 100%;
margin-bottom: 10px;
}
.input-group { .input-group {
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
@@ -222,14 +472,66 @@ input[type="range"]::-moz-range-thumb {
} }
.difficulty-indicator { .difficulty-indicator {
margin: 20px 0; margin: 20px 0 40px 0;
font-size: 1.2rem;
display: flex; display: flex;
justify-content: center; flex-direction: column;
gap: 10px;
align-items: center; align-items: center;
gap: 5px;
}
.label-row {
display: flex;
align-items: center;
gap: 8px;
}
.help-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
border-radius: 50%;
transition: color 0.3s, background 0.3s;
}
.help-btn:hover {
color: var(--accent-cyan);
background: rgba(0, 242, 255, 0.1);
}
.icon-sm {
width: 16px;
height: 16px;
}
.difficulty-row {
display: flex;
flex-direction: row;
gap: 8px;
align-items: baseline;
justify-content: center;
white-space: nowrap; white-space: nowrap;
height: 1.5em; /* Reserve space for one line of text */ flex-wrap: nowrap;
}
.label {
font-size: 1rem;
color: var(--text-muted);
}
.level {
font-size: 1.4rem;
font-weight: bold;
text-transform: uppercase;
line-height: 1.2;
}
.percentage {
font-size: 1rem;
font-weight: bold;
} }
.difficulty-indicator .label { .difficulty-indicator .label {
@@ -251,6 +553,25 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.9rem; font-size: 0.9rem;
} }
.btn-text {
background: none;
border: none;
color: var(--accent-cyan);
font-size: 0.9rem;
cursor: pointer;
text-decoration: underline;
opacity: 0.8;
transition: opacity 0.3s;
}
.btn-text:hover {
opacity: 1;
}
.advanced-toggle {
margin-bottom: 10px;
}
.actions { .actions {
display: flex; display: flex;
gap: 15px; gap: 15px;

View File

@@ -1,11 +1,10 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useTimer } from '@/composables/useTimer'; import { formatTime } from '@/utils/timeUtils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { formatTime } = useTimer();
const { t } = useI18n(); const { t } = useI18n();
const isVisible = ref(false); const isVisible = ref(false);

View File

@@ -118,6 +118,8 @@ const computeCellSize = () => {
const gap = parseFloat(gapRaw); const gap = parseFloat(gapRaw);
const gridPad = parseFloat(gridPadRaw); const gridPad = parseFloat(gridPadRaw);
const isDesktop = window.matchMedia('(min-width: 769px)').matches;
let containerWidth; let containerWidth;
if (scrollWrapper.value) { if (scrollWrapper.value) {
containerWidth = scrollWrapper.value.clientWidth; containerWidth = scrollWrapper.value.clientWidth;
@@ -131,8 +133,15 @@ const computeCellSize = () => {
const availableForGrid = Math.max(0, containerWidth - hintWidth); const availableForGrid = Math.max(0, containerWidth - hintWidth);
const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size); const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size);
// Keep min 18, max 36
cellSize.value = Math.max(18, Math.min(36, size)); if (isDesktop) {
// Desktop: Allow overflow, use comfortable size
cellSize.value = 30;
} else {
// Mobile: Fit to screen
// Keep min 18, max 36
cellSize.value = Math.max(18, Math.min(36, size));
}
}; };
const handleGlobalMouseUp = () => { const handleGlobalMouseUp = () => {
@@ -258,8 +267,15 @@ watch(() => store.size, async () => {
/* Desktop: Remove scroll behavior to ensure full grid visibility */ /* Desktop: Remove scroll behavior to ensure full grid visibility */
@media (min-width: 769px) { @media (min-width: 769px) {
.game-board-wrapper { .game-board-wrapper {
overflow-x: auto; /* Allow scrolling if grid is too large (e.g. 80x80) */ overflow: visible;
align-items: center; /* Center the grid on desktop */ width: max-content;
min-width: 100%;
margin: 0 auto; /* Center the wrapper safely */
align-items: flex-start; /* Prevent cropping when centered */
}
.game-container {
/* margin: 0 auto; - wrapper handles centering now */
} }
} }

View File

@@ -435,8 +435,10 @@ watch(isMobileMenuOpen, (val) => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
z-index: 100; z-index: 100;
position: sticky; position: fixed;
top: 0; top: 0;
left: 0;
right: 0;
} }
.nav-left { .nav-left {

View File

@@ -0,0 +1,320 @@
<script setup>
import { ref, computed } from 'vue';
import { generateRandomGrid, calculateHints } from '@/utils/puzzleUtils';
import { solvePuzzle } from '@/utils/solver';
import { useI18n } from '@/composables/useI18n';
import { X, Play, Square, RotateCcw } from 'lucide-vue-next';
const emit = defineEmits(['close']);
const { t } = useI18n();
const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50];
const DENSITIES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
const SAMPLES_PER_POINT = 10; // Reduced for web performance demo
const isRunning = ref(false);
const progress = ref(0);
const currentStatus = ref('');
const results = ref([]);
const simulationSpeed = ref(1); // 1 = Normal, 2 = Fast (less render updates)
let stopRequested = false;
const displayStatus = computed(() => {
if (!currentStatus.value) return t('simulation.status.ready');
return currentStatus.value;
});
const startSimulation = async () => {
if (isRunning.value) return;
isRunning.value = true;
stopRequested = false;
results.value = [];
progress.value = 0;
const totalSteps = SIZES.length * DENSITIES.length;
let stepCount = 0;
for (const size of SIZES) {
for (const density of DENSITIES) {
if (stopRequested) {
currentStatus.value = t('simulation.status.stopped');
isRunning.value = false;
return;
}
currentStatus.value = t('simulation.status.simulating', {
size: size,
density: (density * 100).toFixed(0)
});
let totalSolved = 0;
// Run samples
for (let i = 0; i < SAMPLES_PER_POINT; i++) {
const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid);
const { percentSolved } = solvePuzzle(rowHints, colHints);
totalSolved += percentSolved;
// Yield to UI every few samples to keep it responsive
if (i % 2 === 0) await new Promise(r => setTimeout(r, 0));
}
const avgSolved = totalSolved / SAMPLES_PER_POINT;
results.value.unshift({
size,
density,
avgSolved: avgSolved.toFixed(1)
});
stepCount++;
progress.value = (stepCount / totalSteps) * 100;
}
}
isRunning.value = false;
currentStatus.value = t('simulation.status.completed');
};
const stopSimulation = () => {
stopRequested = true;
};
const getRowColor = (solved) => {
if (solved >= 90) return 'color-easy';
if (solved >= 60) return 'color-harder';
if (solved >= 30) return 'color-hardest';
return 'color-extreme';
};
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal glass-panel">
<div class="header">
<h2>{{ t('simulation.title') }}</h2>
<button class="close-btn" @click="emit('close')">
<X />
</button>
</div>
<div class="content">
<div class="controls">
<div class="status-bar">
<div class="status-text">{{ displayStatus }}</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
</div>
<div class="actions">
<button v-if="!isRunning" class="btn-neon" @click="startSimulation">
<Play class="icon" /> {{ t('simulation.start') }}
</button>
<button v-else class="btn-neon secondary" @click="stopSimulation">
<Square class="icon" /> {{ t('simulation.stop') }}
</button>
</div>
</div>
<div class="results-container">
<table class="results-table">
<thead>
<tr>
<th>{{ t('simulation.table.size') }}</th>
<th>{{ t('simulation.table.density') }}</th>
<th>{{ t('simulation.table.solved') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in results" :key="idx" :class="getRowColor(row.avgSolved)">
<td>{{ row.size }}x{{ row.size }}</td>
<td>{{ (row.density * 100).toFixed(0) }}%</td>
<td>{{ row.avgSolved }}%</td>
</tr>
</tbody>
</table>
<div v-if="results.length === 0" class="empty-state">
{{ t('simulation.empty') }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--modal-overlay);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
animation: fadeIn 0.3s ease;
}
.modal {
padding: 30px;
width: 90%;
max-width: 600px;
height: 80vh;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-cyan);
box-shadow: 0 0 50px rgba(0, 242, 255, 0.2);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
h2 {
color: var(--accent-cyan);
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 5px;
}
.close-btn:hover {
color: var(--text-color);
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
overflow: hidden;
}
.controls {
display: flex;
flex-direction: column;
gap: 15px;
padding-bottom: 15px;
border-bottom: 1px solid var(--panel-border);
}
.status-bar {
display: flex;
flex-direction: column;
gap: 5px;
}
.status-text {
font-size: 0.9rem;
color: var(--text-muted);
}
.progress-track {
width: 100%;
height: 4px;
background: var(--panel-bg-strong);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-cyan);
transition: width 0.3s ease;
}
.actions {
display: flex;
justify-content: flex-end;
}
.btn-neon {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 0.9rem;
}
.icon {
width: 16px;
height: 16px;
}
.results-container {
flex: 1;
overflow-y: auto;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 10px;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.results-table th {
text-align: left;
padding: 8px;
color: var(--text-muted);
border-bottom: 1px solid var(--panel-border);
position: sticky;
top: 0;
background: var(--panel-bg);
}
.results-table td {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.color-easy { color: #33ff33; }
.color-harder { color: #ffff33; }
.color-hardest { color: #ff9933; }
.color-extreme { color: #ff3333; }
.empty-state {
padding: 40px;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
/* Scrollbar styling */
.results-container::-webkit-scrollbar {
width: 8px;
}
.results-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
.results-container::-webkit-scrollbar-thumb {
background: var(--panel-border);
border-radius: 4px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -1,12 +1,11 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useTimer } from '@/composables/useTimer'; import { formatTime } from '@/utils/timeUtils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { RotateCcw, RefreshCw, Eye, Undo } from 'lucide-vue-next'; import { RotateCcw, RefreshCw, Eye, Undo } from 'lucide-vue-next';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { formatTime } = useTimer();
const { t } = useI18n(); const { t } = useI18n();
const formattedTime = computed(() => formatTime(store.elapsedTime)); const formattedTime = computed(() => formatTime(store.elapsedTime));

View File

@@ -3,17 +3,20 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Fireworks } from 'fireworks-js'; import { Fireworks } from 'fireworks-js';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTimer } from '@/composables/useTimer'; import { formatTime } from '@/utils/timeUtils';
import { playFanfare, cleanupAudio } from '@/utils/audio';
import {
buildShareUrl,
downloadShareSVG as utilsDownloadShareSVG,
downloadShareImage as utilsDownloadShareImage,
createShareBlob
} from '@/utils/shareUtils';
import { Download, FileCode } from 'lucide-vue-next'; import { Download, FileCode } from 'lucide-vue-next';
import { calculateDifficulty } from '@/utils/puzzleUtils';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t } = useI18n(); const { t } = useI18n();
const { formatTime } = useTimer();
const fireworksRef = ref(null); const fireworksRef = ref(null);
let fireworksInstance = null; let fireworksInstance = null;
let audioContext = null;
let masterGain = null;
const shareInProgress = ref(false); const shareInProgress = ref(false);
const formattedTime = computed(() => formatTime(store.elapsedTime)); const formattedTime = computed(() => formatTime(store.elapsedTime));
@@ -29,57 +32,6 @@ const handleKeyDown = (e) => {
} }
}; };
const playFanfare = async () => {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
audioContext = new AudioCtx();
if (audioContext.state === 'suspended') {
try {
await audioContext.resume();
} catch {
return;
}
}
masterGain = audioContext.createGain();
masterGain.gain.value = 0.25; // Slightly louder but softer tone
masterGain.connect(audioContext.destination);
const now = audioContext.currentTime;
const playNote = (freq, startTime, duration) => {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
// Mix of sine and triangle for a bell-like quality
osc.type = 'sine';
osc.frequency.value = freq;
// Envelope for elegant bell/chime sound
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Soft attack
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Long release
osc.connect(gain);
gain.connect(masterGain);
osc.start(startTime);
osc.stop(startTime + duration + 0.1);
};
// C Major 7 Arpeggio sequence (C5, E5, G5, B5, C6) - Elegant & Uplifting
const sequence = [
{ freq: 523.25, time: 0.0, dur: 0.8 }, // C5
{ freq: 659.25, time: 0.1, dur: 0.8 }, // E5
{ freq: 783.99, time: 0.2, dur: 0.8 }, // G5
{ freq: 987.77, time: 0.3, dur: 0.8 }, // B5 (Maj7)
{ freq: 1046.50, time: 0.4, dur: 2.0 }, // C6 (High C resolve)
// Add a bass root note at the end for fullness
{ freq: 523.25, time: 0.4, dur: 2.0 } // C5
];
sequence.forEach(note => playNote(note.freq, now + note.time, note.dur));
};
const triggerVibration = () => { const triggerVibration = () => {
if (!('vibrate' in navigator)) return; if (!('vibrate' in navigator)) return;
const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches; const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches;
@@ -89,285 +41,19 @@ const triggerVibration = () => {
} }
}; };
const buildShareCanvas = () => { const getShareData = () => ({
const grid = store.playerGrid; grid: store.playerGrid,
if (!grid || !grid.length) return null; size: store.size,
const appUrl = 'https://nonograms.7u.pl/'; currentDensity: store.currentDensity,
const size = store.size; guideUsageCount: store.guideUsageCount
const maxBoard = 640; });
const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size;
const padding = 28;
const headerHeight = 64;
const footerHeight = 28;
const infoHeight = 40; // New space for difficulty/guide info
const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
const scale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.scale(scale, scale);
const bg = ctx.createLinearGradient(0, 0, width, height);
bg.addColorStop(0, '#1b2a4a');
bg.addColorStop(1, '#0a1324');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(12, 12, width - 24, height - 24);
ctx.fillStyle = '#e8fbff';
ctx.font = '700 26px "Segoe UI", sans-serif';
ctx.fillText(t('app.title'), padding, padding + 10);
ctx.font = '600 16px "Segoe UI", sans-serif';
ctx.fillText(`${t('win.time')} ${formattedTime.value}`, padding, padding + 34);
// Difficulty & Density Info
const densityPercent = Math.round(store.currentDensity * 100);
const difficultyKey = calculateDifficulty(store.currentDensity);
let diffColor = '#33ff33';
if (difficultyKey === 'extreme') diffColor = '#ff3333';
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
else if (difficultyKey === 'harder') diffColor = '#ffff33';
const difficultyText = t(`difficulty.${difficultyKey}`);
ctx.font = '600 14px "Segoe UI", sans-serif';
// Right aligned difficulty info
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
const diffWidth = ctx.measureText(diffLabel).width;
ctx.fillStyle = diffColor;
ctx.fillText(diffLabel, width - padding - diffWidth, padding + 34);
const gridX = padding;
const gridY = padding + headerHeight;
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillRect(gridX, gridY, boardSize, boardSize);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 1;
for (let i = 0; i <= size; i++) {
const x = gridX + i * cellSize;
const y = gridY + i * cellSize;
ctx.beginPath();
ctx.moveTo(x, gridY);
ctx.lineTo(x, gridY + boardSize);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gridX, y);
ctx.lineTo(gridX + boardSize, y);
ctx.stroke();
}
ctx.fillStyle = '#00f2fe';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const state = grid[r]?.[c];
if (state === 1) {
const x = gridX + c * cellSize + 1;
const y = gridY + r * cellSize + 1;
ctx.fillRect(x, y, cellSize - 2, cellSize - 2);
} else if (state === 2) {
const x = gridX + c * cellSize + cellSize * 0.2;
const y = gridY + r * cellSize + cellSize * 0.2;
const d = cellSize * 0.6;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + d, y + d);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + d, y);
ctx.lineTo(x, y + d);
ctx.stroke();
}
}
}
// Guide Usage Info (Dirty Flag)
if (store.guideUsageCount > 0) {
ctx.fillStyle = '#ff4d4d';
ctx.font = '600 14px "Segoe UI", sans-serif';
const totalCells = store.size * store.size;
const percent = Math.min(100, Math.round((store.guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: store.guideUsageCount, percent });
ctx.fillText(`⚠️ ${guideText}`, padding, height - padding - footerHeight + 10);
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
ctx.font = '500 14px "Segoe UI", sans-serif';
ctx.fillText(appUrl, padding, height - padding + 6);
return canvas;
};
const buildShareSVG = () => {
const grid = store.playerGrid;
if (!grid || !grid.length) return null;
const appUrl = 'https://nonograms.7u.pl/';
const size = store.size;
const maxBoard = 640;
const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size;
const padding = 28;
const headerHeight = 64;
const footerHeight = 28;
const infoHeight = 40;
const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
// Colors
const bgGradientStart = '#1b2a4a';
const bgGradientEnd = '#0a1324';
const overlayColor = 'rgba(0, 0, 0, 0.35)';
const textColor = '#e8fbff';
const gridColor = 'rgba(255, 255, 255, 0.06)';
const gridLineColor = 'rgba(255, 255, 255, 0.12)';
const filledColor = '#00f2fe';
const crossColor = 'rgba(255, 255, 255, 0.5)';
const urlColor = 'rgba(255, 255, 255, 0.75)';
// Difficulty Logic
const densityPercent = Math.round(store.currentDensity * 100);
const difficultyKey = calculateDifficulty(store.currentDensity);
let diffColor = '#33ff33';
if (difficultyKey === 'extreme') diffColor = '#ff3333';
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
else if (difficultyKey === 'harder') diffColor = '#ffff33';
const difficultyText = t(`difficulty.${difficultyKey}`);
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`;
// Background
svgContent += `
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${bgGradientStart}"/>
<stop offset="100%" stop-color="${bgGradientEnd}"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<rect x="12" y="12" width="${width - 24}" height="${height - 24}" fill="${overlayColor}"/>
`;
// Text: Title & Time
svgContent += `
<text x="${padding}" y="${padding + 28}" font-family="Segoe UI, sans-serif" font-weight="700" font-size="26" fill="${textColor}">${t('app.title')}</text>
<text x="${padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="16" fill="${textColor}">${t('win.time')} ${formattedTime.value}</text>
`;
// Text: Difficulty (Right Aligned - manual approx or end anchor)
svgContent += `
<text x="${width - padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="${diffColor}" text-anchor="end">${diffLabel}</text>
`;
const gridX = padding;
const gridY = padding + headerHeight;
// Grid Background
svgContent += `<rect x="${gridX}" y="${gridY}" width="${boardSize}" height="${boardSize}" fill="${gridColor}"/>`;
// Grid Lines
let gridLines = '';
for (let i = 0; i <= size; i++) {
const pos = i * cellSize;
// Vertical
gridLines += `<line x1="${gridX + pos}" y1="${gridY}" x2="${gridX + pos}" y2="${gridY + boardSize}" stroke="${gridLineColor}" stroke-width="1"/>`;
// Horizontal
gridLines += `<line x1="${gridX}" y1="${gridY + pos}" x2="${gridX + boardSize}" y2="${gridY + pos}" stroke="${gridLineColor}" stroke-width="1"/>`;
}
svgContent += gridLines;
// Cells
let cells = '';
const lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const state = grid[r]?.[c];
const cx = gridX + c * cellSize;
const cy = gridY + r * cellSize;
if (state === 1) { // Filled
cells += `<rect x="${cx + 1}" y="${cy + 1}" width="${cellSize - 2}" height="${cellSize - 2}" fill="${filledColor}"/>`;
} else if (state === 2) { // Cross
const d = cellSize * 0.6;
const off = cellSize * 0.2;
cells += `
<path d="M${cx + off} ${cy + off} L${cx + off + d} ${cy + off + d} M${cx + off + d} ${cy + off} L${cx + off} ${cy + off + d}"
stroke="${crossColor}" stroke-width="${lineWidth}" stroke-linecap="round"/>
`;
}
}
}
svgContent += cells;
// Guide Usage
if (store.guideUsageCount > 0) {
const totalCells = store.size * store.size;
const percent = Math.min(100, Math.round((store.guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: store.guideUsageCount, percent });
svgContent += `<text x="${padding}" y="${height - padding - footerHeight + 10}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="#ff4d4d">⚠️ ${guideText}</text>`;
}
// URL
svgContent += `<text x="${padding}" y="${height - padding + 6}" font-family="Segoe UI, sans-serif" font-weight="500" font-size="14" fill="${urlColor}">${appUrl}</text>`;
svgContent += '</svg>';
return svgContent;
};
const canvasToBlob = (canvas) => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png'));
const createShareBlob = async () => {
const canvas = buildShareCanvas();
if (!canvas) return null;
return canvasToBlob(canvas);
};
const downloadShareSVG = () => { const downloadShareSVG = () => {
const svgString = buildShareSVG(); utilsDownloadShareSVG(getShareData(), t, formattedTime.value);
if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nonogram-${store.size}x${store.size}.svg`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}; };
const downloadShareImage = async () => { const downloadShareImage = async () => {
const blob = await createShareBlob(); await utilsDownloadShareImage(getShareData(), t, formattedTime.value);
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nonogram-${store.size}x${store.size}.png`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const buildShareUrl = (target, text, url) => {
const encodedText = encodeURIComponent(text);
const encodedUrl = encodeURIComponent(url);
if (target === 'x') {
return `https://x.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
}
if (target === 'facebook') {
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`;
}
if (target === 'whatsapp') {
return `https://wa.me/?text=${encodeURIComponent(`${text} ${url}`)}`;
}
return '';
}; };
const shareTo = async (target) => { const shareTo = async (target) => {
@@ -381,7 +67,7 @@ const shareTo = async (target) => {
try { try {
// Try native share first if available (supports images) // Try native share first if available (supports images)
if (navigator.share && navigator.canShare) { if (navigator.share && navigator.canShare) {
const blob = await createShareBlob(); const blob = await createShareBlob(getShareData(), t, formattedTime.value);
if (blob) { if (blob) {
const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' }); const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' });
if (navigator.canShare({ files: [file] })) { if (navigator.canShare({ files: [file] })) {
@@ -405,10 +91,6 @@ const shareTo = async (target) => {
} }
// Fallback: Direct Link + Download // Fallback: Direct Link + Download
// Open window immediately if possible (though we awaited above, so it might be blocked,
// but we can't do much about it if we want to try native share first).
// Ideally, for Desktop, navigator.share is undefined so we skip the await above.
if (shareUrl) { if (shareUrl) {
window.open(shareUrl, '_blank', 'noopener'); window.open(shareUrl, '_blank', 'noopener');
} }
@@ -449,11 +131,7 @@ onUnmounted(() => {
if ('vibrate' in navigator) { if ('vibrate' in navigator) {
navigator.vibrate(0); navigator.vibrate(0);
} }
if (audioContext) { cleanupAudio();
audioContext.close();
audioContext = null;
}
masterGain = null;
}); });
</script> </script>
@@ -471,27 +149,27 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="share"> <div class="social-section">
<div class="share-title">{{ t('win.shareTitle') }}</div> <div class="social-header">{{ t('win.shareTitle') }}</div>
<div class="share-buttons"> <div class="social-grid">
<!-- X (Twitter) --> <!-- X (Twitter) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')"> <button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')">
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M18.901 3H22l-7.21 8.26L23 21h-6.66L11.13 14.76 5.66 21H2.56l7.73-8.83L1 3h6.8l4.63 5.56L18.9 3h.001zm-1.2 15.9h1.77L6.44 5.1H4.44l13.26 13.8z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M18.901 3H22l-7.21 8.26L23 21h-6.66L11.13 14.76 5.66 21H2.56l7.73-8.83L1 3h6.8l4.63 5.56L18.9 3h.001zm-1.2 15.9h1.77L6.44 5.1H4.44l13.26 13.8z"/></svg>
</button> </button>
<!-- Facebook --> <!-- Facebook -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')"> <button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')">
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.791-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.791-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</button> </button>
<!-- WhatsApp --> <!-- WhatsApp -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')"> <button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')">
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
</button> </button>
<!-- Download Screenshot (Compact) --> <!-- Download Screenshot (Compact) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage"> <button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage">
<Download :size="20" /> <Download :size="20" />
</button> </button>
<!-- Download SVG (Compact) --> <!-- Download SVG (Compact) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" aria-label="Download SVG" @click="downloadShareSVG"> <button class="btn-neon secondary social-item" :disabled="shareInProgress" aria-label="Download SVG" @click="downloadShareSVG">
<FileCode :size="20" /> <FileCode :size="20" />
</button> </button>
</div> </div>
@@ -574,14 +252,14 @@ p {
margin-left: 10px; margin-left: 10px;
} }
.share { .social-section {
margin-bottom: 24px; margin-bottom: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.share-title { .social-header {
font-size: 0.95rem; font-size: 0.95rem;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
@@ -589,14 +267,14 @@ p {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.share-buttons { .social-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
justify-content: center; justify-content: center;
} }
.share-btn { .social-item {
width: 44px; width: 44px;
height: 44px; height: 44px;
padding: 0; padding: 0;
@@ -606,14 +284,14 @@ p {
justify-content: center; justify-content: center;
} }
.share-icon { .social-icon {
width: 22px; width: 22px;
height: 22px; height: 22px;
display: block; display: block;
} }
.share-download { .social-download {
align-self: center; align-self: center;
padding: 8px 18px; padding: 8px 18px;
font-size: 0.85rem; font-size: 0.85rem;

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ export function useNonogram() {
}; };
const stopDrag = () => { const stopDrag = () => {
store.endInteraction();
isDragging.value = false; isDragging.value = false;
dragMode.value = null; dragMode.value = null;
}; };

View File

@@ -9,8 +9,8 @@ export function useSolver() {
const isPlaying = ref(false); const isPlaying = ref(false);
const isProcessing = ref(false); const isProcessing = ref(false);
const speedIndex = ref(0); const speedIndex = ref(0);
const speeds = [1000, 500, 250, 125]; const speeds = [1000, 500, 250, 125, 62];
const speedLabels = ['x1', 'x2', 'x3', 'x4']; const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16'];
const statusText = ref(t('guide.waiting')); const statusText = ref(t('guide.waiting'));
let intervalId = null; let intervalId = null;

View File

@@ -1,49 +0,0 @@
import { ref, onUnmounted } from 'vue';
export function useTimer() {
const time = ref(0);
const timerInterval = ref(null);
const isRunning = ref(false);
const formatTime = (seconds) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const start = () => {
if (isRunning.value) return;
isRunning.value = true;
const startTime = Date.now() - (time.value * 1000);
timerInterval.value = setInterval(() => {
time.value = Math.floor((Date.now() - startTime) / 1000);
}, 1000);
};
const stop = () => {
if (timerInterval.value) {
clearInterval(timerInterval.value);
timerInterval.value = null;
}
isRunning.value = false;
};
const reset = () => {
stop();
time.value = 0;
};
onUnmounted(() => {
stop();
});
return {
time,
isRunning,
start,
stop,
reset,
formatTime
};
}

53
src/constants/puzzles.js Normal file
View File

@@ -0,0 +1,53 @@
export const PUZZLES = {
easy: {
id: 'easy',
name: 'Uśmiech',
size: 5,
grid: [
[0, 1, 0, 1, 0],
[0, 1, 0, 1, 0],
[0, 0, 0, 0, 0],
[1, 0, 0, 0, 1],
[0, 1, 1, 1, 0]
]
},
medium: {
id: 'medium',
name: 'Domek',
size: 10,
grid: [
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0]
]
},
hard: {
id: 'hard',
name: 'Statek',
size: 15,
grid: [
[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0],
[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0],
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0]
]
}
};

View File

@@ -1,61 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { generateRandomGrid } from '@/utils/puzzleUtils'; import { generateRandomGrid, calculateHints, calculateLineHints, validateLine } from '@/utils/puzzleUtils';
import { PUZZLES } from '@/constants/puzzles';
// Definicje zagadek (Static Puzzles)
const PUZZLES = {
easy: {
id: 'easy',
name: 'Uśmiech',
size: 5,
grid: [
[0, 1, 0, 1, 0],
[0, 1, 0, 1, 0],
[0, 0, 0, 0, 0],
[1, 0, 0, 0, 1],
[0, 1, 1, 1, 0]
]
},
medium: {
id: 'medium',
name: 'Domek',
size: 10,
grid: [
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0]
]
},
hard: {
id: 'hard',
name: 'Statek',
size: 15,
grid: [
[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0],
[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0],
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0]
]
}
};
export const usePuzzleStore = defineStore('puzzle', () => { export const usePuzzleStore = defineStore('puzzle', () => {
// State // State
@@ -75,6 +21,7 @@ export const usePuzzleStore = defineStore('puzzle', () => {
// History for undo // History for undo
const history = ref([]); const history = ref([]);
const currentTransaction = ref(null);
// Progress State // Progress State
const totalCellsToFill = computed(() => { const totalCellsToFill = computed(() => {
@@ -150,18 +97,39 @@ export const usePuzzleStore = defineStore('puzzle', () => {
playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0)); playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0));
moves.value = 0; moves.value = 0;
history.value = []; history.value = [];
currentTransaction.value = null;
} }
function pushHistory() { function startInteraction() {
const gridCopy = playerGrid.value.map(row => [...row]); currentTransaction.value = [];
history.value.push(gridCopy); }
if (history.value.length > 50) history.value.shift();
function endInteraction() {
if (currentTransaction.value && currentTransaction.value.length > 0) {
history.value.push(currentTransaction.value);
if (history.value.length > 50) history.value.shift();
saveState();
}
currentTransaction.value = null;
} }
function undo() { function undo() {
if (history.value.length === 0 || isGameWon.value) return; if (history.value.length === 0 || isGameWon.value) return;
const previousState = history.value.pop();
playerGrid.value = previousState; const transaction = history.value.pop();
// Handle legacy history (full grid snapshot)
if (!Array.isArray(transaction) || (transaction.length > 0 && Array.isArray(transaction[0]))) {
playerGrid.value = transaction;
} else {
// Handle new history (list of changes)
// Revert changes in reverse order
for (let i = transaction.length - 1; i >= 0; i--) {
const { r, c, oldVal } = transaction[i];
playerGrid.value[r][c] = oldVal;
}
}
moves.value++; moves.value++;
saveState(); saveState();
} }
@@ -169,8 +137,6 @@ export const usePuzzleStore = defineStore('puzzle', () => {
function toggleCell(r, c, isRightClick = false) { function toggleCell(r, c, isRightClick = false) {
if (isGameWon.value) return; if (isGameWon.value) return;
pushHistory();
const currentState = playerGrid.value[r][c]; const currentState = playerGrid.value[r][c];
let newState; let newState;
@@ -182,39 +148,79 @@ export const usePuzzleStore = defineStore('puzzle', () => {
newState = currentState === 1 ? 0 : 1; newState = currentState === 1 ? 0 : 1;
} }
playerGrid.value[r][c] = newState; // This triggers reactivity if (currentState === newState) return;
// Apply change
playerGrid.value[r][c] = newState;
// Record history
const change = { r, c, oldVal: currentState, newVal: newState };
if (currentTransaction.value) {
currentTransaction.value.push(change);
} else {
// Atomic change if no interaction started
history.value.push([change]);
if (history.value.length > 50) history.value.shift();
saveState();
}
moves.value++; moves.value++;
checkWin(); checkWin();
saveState(); // saveState(); // Moved to endInteraction or atomic block
} }
function setCell(r, c, state) { function setCell(r, c, state) {
if (isGameWon.value) return; if (isGameWon.value) return;
if (playerGrid.value[r][c] !== state) { const currentState = playerGrid.value[r][c];
pushHistory();
if (currentState !== state) {
// Apply change
playerGrid.value[r][c] = state; playerGrid.value[r][c] = state;
// Record history
const change = { r, c, oldVal: currentState, newVal: state };
if (currentTransaction.value) {
currentTransaction.value.push(change);
} else {
history.value.push([change]);
if (history.value.length > 50) history.value.shift();
saveState();
}
moves.value++; moves.value++;
checkWin(); checkWin();
saveState(); // saveState(); // Moved to endInteraction or atomic block
} }
} }
function checkWin() { function checkWin() {
let correct = true; let correct = true;
// Calculate expected hints from solution (truth)
// We do this dynamically to ensure we always check against the rules of the board
const solutionRows = solution.value;
const solutionCols = Array(size.value).fill().map((_, c) => solution.value.map(r => r[c]));
// Check Rows
for (let r = 0; r < size.value; r++) { for (let r = 0; r < size.value; r++) {
const targetHints = calculateLineHints(solutionRows[r]);
const playerLine = playerGrid.value[r];
if (!validateLine(playerLine, targetHints)) {
correct = false;
break;
}
}
if (correct) {
// Check Columns
for (let c = 0; c < size.value; c++) { for (let c = 0; c < size.value; c++) {
const playerCell = playerGrid.value[r][c]; const targetHints = calculateLineHints(solutionCols[c]);
const solutionCell = solution.value[r][c]; const playerLine = playerGrid.value.map(row => row[c]);
if (!validateLine(playerLine, targetHints)) {
const isFilled = playerCell === 1;
const shouldBeFilled = solutionCell === 1;
if (isFilled !== shouldBeFilled) {
correct = false; correct = false;
break; break;
} }
} }
if (!correct) break;
} }
if (correct) { if (correct) {
@@ -289,12 +295,6 @@ export const usePuzzleStore = defineStore('puzzle', () => {
return false; return false;
} }
// Duplicate initGame removed
// Duplicate initCustomGame removed
// Duplicate toggleCell/setCell removed
function resetGame() { function resetGame() {
if (currentLevelId.value === 'custom') { if (currentLevelId.value === 'custom') {
resetGrid(); resetGrid();
@@ -343,7 +343,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
hasUsedGuide, hasUsedGuide,
guideUsageCount, guideUsageCount,
currentDensity, currentDensity,
markGuideUsed markGuideUsed,
startInteraction,
endInteraction
}; };
}); });

View File

@@ -130,7 +130,6 @@
} }
html { html {
overflow-x: hidden;
width: 100%; width: 100%;
} }
@@ -139,18 +138,18 @@ body {
padding: 20px; padding: 20px;
font-family: 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif; font-family: 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif;
background: var(--bg-gradient); background: var(--bg-gradient);
background-attachment: fixed;
background-size: cover;
color: var(--text-color); color: var(--text-color);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
overflow-x: hidden;
} }
/* Ensure no other content is visible */ /* Ensure no other content is visible */
#app { #app {
width: 100%; width: 100%;
max-width: 100vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

72
src/utils/audio.js Normal file
View File

@@ -0,0 +1,72 @@
let audioContext = null;
let masterGain = null;
export async function playFanfare() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
if (!audioContext) {
audioContext = new AudioCtx();
}
if (audioContext.state === 'suspended') {
try {
await audioContext.resume();
} catch {
return;
}
}
// Re-create gain if needed or just use it.
// In the original code, it was created every time playFanfare was called?
// Original: masterGain = audioContext.createGain();
// It's better to create it once or manage it properly.
// Let's stick to the original logic but encapsulated.
masterGain = audioContext.createGain();
masterGain.gain.value = 0.25; // Slightly louder but softer tone
masterGain.connect(audioContext.destination);
const now = audioContext.currentTime;
const playNote = (freq, startTime, duration) => {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
// Mix of sine and triangle for a bell-like quality
osc.type = 'sine';
osc.frequency.value = freq;
// Envelope for elegant bell/chime sound
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Soft attack
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Long release
osc.connect(gain);
gain.connect(masterGain);
osc.start(startTime);
osc.stop(startTime + duration + 0.1);
};
// C Major 7 Arpeggio sequence (C5, E5, G5, B5, C6) - Elegant & Uplifting
const sequence = [
{ freq: 523.25, time: 0.0, dur: 0.8 }, // C5
{ freq: 659.25, time: 0.1, dur: 0.8 }, // E5
{ freq: 783.99, time: 0.2, dur: 0.8 }, // G5
{ freq: 987.77, time: 0.3, dur: 0.8 }, // B5 (Maj7)
{ freq: 1046.50, time: 0.4, dur: 2.0 }, // C6 (High C resolve)
// Add a bass root note at the end for fullness
{ freq: 523.25, time: 0.4, dur: 2.0 } // C5
];
sequence.forEach(note => playNote(note.freq, now + note.time, note.dur));
}
export function cleanupAudio() {
if (audioContext) {
audioContext.close();
audioContext = null;
}
masterGain = null;
}

View File

@@ -1,3 +1,29 @@
export function calculateLineHints(line) {
const hints = [];
let currentRun = 0;
for (const cell of line) {
if (cell === 1) {
currentRun++;
} else {
if (currentRun > 0) {
hints.push(currentRun);
currentRun = 0;
}
}
}
if (currentRun > 0) {
hints.push(currentRun);
}
return hints.length > 0 ? hints : [0];
}
export function validateLine(line, targetHints) {
const currentHints = calculateLineHints(line);
if (currentHints.length !== targetHints.length) return false;
return currentHints.every((h, i) => h === targetHints[i]);
}
export function calculateHints(grid) { export function calculateHints(grid) {
if (!grid || grid.length === 0) return { rowHints: [], colHints: [] }; if (!grid || grid.length === 0) return { rowHints: [], colHints: [] };
@@ -7,34 +33,16 @@ export function calculateHints(grid) {
// Row Hints // Row Hints
for (let r = 0; r < size; r++) { for (let r = 0; r < size; r++) {
const hints = []; rowHints.push(calculateLineHints(grid[r]));
let count = 0;
for (let c = 0; c < size; c++) {
if (grid[r][c] === 1) {
count++;
} else if (count > 0) {
hints.push(count);
count = 0;
}
}
if (count > 0) hints.push(count);
rowHints.push(hints.length > 0 ? hints : [0]);
} }
// Col Hints // Col Hints
for (let c = 0; c < size; c++) { for (let c = 0; c < size; c++) {
const hints = []; const col = [];
let count = 0;
for (let r = 0; r < size; r++) { for (let r = 0; r < size; r++) {
if (grid[r][c] === 1) { col.push(grid[r][c]);
count++;
} else if (count > 0) {
hints.push(count);
count = 0;
}
} }
if (count > 0) hints.push(count); colHints.push(calculateLineHints(col));
colHints.push(hints.length > 0 ? hints : [0]);
} }
return { rowHints, colHints }; return { rowHints, colHints };
@@ -52,24 +60,79 @@ export function generateRandomGrid(size, density = 0.5) {
return grid; return grid;
} }
export function calculateDifficulty(density) { export function calculateDifficulty(density, size = 10) {
// Shannon Entropy: H(x) = -x*log2(x) - (1-x)*log2(1-x) // Data derived from Monte Carlo Simulation (Logical Solver)
// Normalized to 0-1 range (since max entropy at 0.5 is 1) // Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] }
// Densities: 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9
// Avoid log(0) const SIM_DATA = {
if (density <= 0 || density >= 1) return 'easy'; 5: [89, 74, 74, 81, 97, 98, 99, 100, 100],
10: [57, 20, 16, 54, 92, 100, 100, 100, 100],
const entropy = -density * Math.log2(density) - (1 - density) * Math.log2(1 - density); 15: [37, 10, 2, 12, 68, 100, 100, 100, 100],
20: [23, 3, 1, 2, 37, 100, 100, 100, 100],
// Thresholds based on entropy 25: [16, 0, 0, 1, 19, 99, 100, 100, 100],
// 0.5 density -> entropy 1.0 (Extreme) 30: [8, 0, 0, 0, 5, 99, 100, 100, 100],
// 0.4/0.6 density -> entropy ~0.97 (Extreme) 35: [6, 0, 0, 0, 4, 91, 100, 100, 100],
// 0.3/0.7 density -> entropy ~0.88 (Hardest) 40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
// 0.2/0.8 density -> entropy ~0.72 (Harder) 45: [2, 0, 0, 0, 1, 82, 100, 100, 100],
// <0.2/>0.8 density -> entropy <0.72 (Easy) 50: [2, 0, 0, 0, 1, 73, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
71: [0, 0, 0, 0, 0, 16, 100, 100, 100],
80: [0, 0, 0, 0, 0, 1, 100, 100, 100]
};
if (entropy >= 0.96) return 'extreme'; // approx 38% - 62% // Helper to get interpolated value from array
if (entropy >= 0.85) return 'hardest'; // approx 28% - 38% & 62% - 72% const getSimulatedSolvedPct = (s, d) => {
if (entropy >= 0.65) return 'harder'; // approx 17% - 28% & 72% - 83% // Find closest sizes
return 'easy'; const sizes = Object.keys(SIM_DATA).map(Number).sort((a, b) => a - b);
let sLower = sizes[0];
let sUpper = sizes[sizes.length - 1];
for (let i = 0; i < sizes.length - 1; i++) {
if (s >= sizes[i] && s <= sizes[i+1]) {
sLower = sizes[i];
sUpper = sizes[i+1];
break;
}
}
// Clamp density to 0.1 - 0.9
const dClamped = Math.max(0.1, Math.min(0.9, d));
// Index in array: 0.1 -> 0, 0.9 -> 8
const dIndex = (dClamped - 0.1) * 10;
const dLowerIdx = Math.floor(dIndex);
const dUpperIdx = Math.ceil(dIndex);
const dFraction = dIndex - dLowerIdx;
// Bilinear Interpolation
// 1. Interpolate Density for Lower Size
const rowLower = SIM_DATA[sLower];
const valLower = rowLower[dLowerIdx] * (1 - dFraction) + (rowLower[dUpperIdx] || rowLower[dLowerIdx]) * dFraction;
// 2. Interpolate Density for Upper Size
const rowUpper = SIM_DATA[sUpper];
const valUpper = rowUpper[dLowerIdx] * (1 - dFraction) + (rowUpper[dUpperIdx] || rowUpper[dLowerIdx]) * dFraction;
// 3. Interpolate Size
if (sLower === sUpper) return valLower;
const sFraction = (s - sLower) / (sUpper - sLower);
return valLower * (1 - sFraction) + valUpper * sFraction;
};
const solvedPct = getSimulatedSolvedPct(size, density);
// Difficulty Score: Inverse of Solved Percent
// 100% Solved -> 0 Difficulty
// 0% Solved -> 100 Difficulty
const value = Math.round(100 - solvedPct);
// Thresholds
let level = 'easy';
if (value >= 90) level = 'extreme'; // < 10% Solved
else if (value >= 60) level = 'hardest'; // < 40% Solved
else if (value >= 30) level = 'harder'; // < 70% Solved
else level = 'easy'; // > 70% Solved
return { level, value };
} }

284
src/utils/shareUtils.js Normal file
View File

@@ -0,0 +1,284 @@
import { calculateDifficulty } from '@/utils/puzzleUtils';
export function buildShareCanvas(data, t, formattedTime) {
const { grid, size, currentDensity, guideUsageCount } = data;
if (!grid || !grid.length) return null;
const appUrl = 'https://nonograms.7u.pl/';
const maxBoard = 640;
const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size;
const padding = 28;
const headerHeight = 64;
const footerHeight = 28;
const infoHeight = 40; // New space for difficulty/guide info
const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
const scale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.scale(scale, scale);
const bg = ctx.createLinearGradient(0, 0, width, height);
bg.addColorStop(0, '#1b2a4a');
bg.addColorStop(1, '#0a1324');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(12, 12, width - 24, height - 24);
ctx.fillStyle = '#e8fbff';
ctx.font = '700 26px "Segoe UI", sans-serif';
ctx.fillText(t('app.title'), padding, padding + 10);
ctx.font = '600 16px "Segoe UI", sans-serif';
ctx.fillText(`${t('win.time')} ${formattedTime}`, padding, padding + 34);
// Difficulty & Density Info
const densityPercent = Math.round(currentDensity * 100);
const { level: difficultyKey } = calculateDifficulty(currentDensity, size);
let diffColor = '#33ff33';
if (difficultyKey === 'extreme') diffColor = '#ff3333';
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
else if (difficultyKey === 'harder') diffColor = '#ffff33';
const difficultyText = t(`difficulty.${difficultyKey}`);
ctx.font = '600 14px "Segoe UI", sans-serif';
// Right aligned difficulty info
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
const diffWidth = ctx.measureText(diffLabel).width;
ctx.fillStyle = diffColor;
ctx.fillText(diffLabel, width - padding - diffWidth, padding + 34);
const gridX = padding;
const gridY = padding + headerHeight;
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillRect(gridX, gridY, boardSize, boardSize);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 1;
for (let i = 0; i <= size; i++) {
const x = gridX + i * cellSize;
const y = gridY + i * cellSize;
ctx.beginPath();
ctx.moveTo(x, gridY);
ctx.lineTo(x, gridY + boardSize);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gridX, y);
ctx.lineTo(gridX + boardSize, y);
ctx.stroke();
}
ctx.fillStyle = '#00f2fe';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const state = grid[r]?.[c];
if (state === 1) {
const x = gridX + c * cellSize + 1;
const y = gridY + r * cellSize + 1;
ctx.fillRect(x, y, cellSize - 2, cellSize - 2);
} else if (state === 2) {
const x = gridX + c * cellSize + cellSize * 0.2;
const y = gridY + r * cellSize + cellSize * 0.2;
const d = cellSize * 0.6;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + d, y + d);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + d, y);
ctx.lineTo(x, y + d);
ctx.stroke();
}
}
}
// Guide Usage Info (Dirty Flag)
if (guideUsageCount > 0) {
ctx.fillStyle = '#ff4d4d';
ctx.font = '600 14px "Segoe UI", sans-serif';
const totalCells = size * size;
const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: guideUsageCount, percent });
ctx.fillText(`⚠️ ${guideText}`, padding, height - padding - footerHeight + 10);
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
ctx.font = '500 14px "Segoe UI", sans-serif';
ctx.fillText(appUrl, padding, height - padding + 6);
return canvas;
}
export function buildShareSVG(data, t, formattedTime) {
const { grid, size, currentDensity, guideUsageCount } = data;
if (!grid || !grid.length) return null;
const appUrl = 'https://nonograms.7u.pl/';
const maxBoard = 640;
const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size;
const padding = 28;
const headerHeight = 64;
const footerHeight = 28;
const infoHeight = 40;
const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
// Colors
const bgGradientStart = '#1b2a4a';
const bgGradientEnd = '#0a1324';
const overlayColor = 'rgba(0, 0, 0, 0.35)';
const textColor = '#e8fbff';
const gridColor = 'rgba(255, 255, 255, 0.06)';
const gridLineColor = 'rgba(255, 255, 255, 0.12)';
const filledColor = '#00f2fe';
const crossColor = 'rgba(255, 255, 255, 0.5)';
const urlColor = 'rgba(255, 255, 255, 0.75)';
// Difficulty Logic
const densityPercent = Math.round(currentDensity * 100);
const { level: difficultyKey } = calculateDifficulty(currentDensity, size);
let diffColor = '#33ff33';
if (difficultyKey === 'extreme') diffColor = '#ff3333';
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
else if (difficultyKey === 'harder') diffColor = '#ffff33';
const difficultyText = t(`difficulty.${difficultyKey}`);
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`;
// Background
svgContent += `
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${bgGradientStart}"/>
<stop offset="100%" stop-color="${bgGradientEnd}"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<rect x="12" y="12" width="${width - 24}" height="${height - 24}" fill="${overlayColor}"/>
`;
// Text: Title & Time
svgContent += `
<text x="${padding}" y="${padding + 28}" font-family="Segoe UI, sans-serif" font-weight="700" font-size="26" fill="${textColor}">${t('app.title')}</text>
<text x="${padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="16" fill="${textColor}">${t('win.time')} ${formattedTime}</text>
`;
// Text: Difficulty (Right Aligned - manual approx or end anchor)
svgContent += `
<text x="${width - padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="${diffColor}" text-anchor="end">${diffLabel}</text>
`;
const gridX = padding;
const gridY = padding + headerHeight;
// Grid Background
svgContent += `<rect x="${gridX}" y="${gridY}" width="${boardSize}" height="${boardSize}" fill="${gridColor}"/>`;
// Grid Lines
let gridLines = '';
for (let i = 0; i <= size; i++) {
const pos = i * cellSize;
// Vertical
gridLines += `<line x1="${gridX + pos}" y1="${gridY}" x2="${gridX + pos}" y2="${gridY + boardSize}" stroke="${gridLineColor}" stroke-width="1"/>`;
// Horizontal
gridLines += `<line x1="${gridX}" y1="${gridY + pos}" x2="${gridX + boardSize}" y2="${gridY + pos}" stroke="${gridLineColor}" stroke-width="1"/>`;
}
svgContent += gridLines;
// Cells
let cells = '';
const lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const state = grid[r]?.[c];
const cx = gridX + c * cellSize;
const cy = gridY + r * cellSize;
if (state === 1) { // Filled
cells += `<rect x="${cx + 1}" y="${cy + 1}" width="${cellSize - 2}" height="${cellSize - 2}" fill="${filledColor}"/>`;
} else if (state === 2) { // Cross
const d = cellSize * 0.6;
const off = cellSize * 0.2;
cells += `
<path d="M${cx + off} ${cy + off} L${cx + off + d} ${cy + off + d} M${cx + off + d} ${cy + off} L${cx + off} ${cy + off + d}"
stroke="${crossColor}" stroke-width="${lineWidth}" stroke-linecap="round"/>
`;
}
}
}
svgContent += cells;
// Guide Usage
if (guideUsageCount > 0) {
const totalCells = size * size;
const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: guideUsageCount, percent });
svgContent += `<text x="${padding}" y="${height - padding - footerHeight + 10}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="#ff4d4d">⚠️ ${guideText}</text>`;
}
// URL
svgContent += `<text x="${padding}" y="${height - padding + 6}" font-family="Segoe UI, sans-serif" font-weight="500" font-size="14" fill="${urlColor}">${appUrl}</text>`;
svgContent += '</svg>';
return svgContent;
}
export const canvasToBlob = (canvas) => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png'));
export const createShareBlob = async (data, t, formattedTime) => {
const canvas = buildShareCanvas(data, t, formattedTime);
if (!canvas) return null;
return canvasToBlob(canvas);
};
export const downloadShareSVG = (data, t, formattedTime) => {
const svgString = buildShareSVG(data, t, formattedTime);
if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nonogram-${data.size}x${data.size}.svg`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export const downloadShareImage = async (data, t, formattedTime) => {
const blob = await createShareBlob(data, t, formattedTime);
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nonogram-${data.size}x${data.size}.png`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export const buildShareUrl = (target, text, url) => {
const encodedText = encodeURIComponent(text);
const encodedUrl = encodeURIComponent(url);
if (target === 'x') {
return `https://x.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
}
if (target === 'facebook') {
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`;
}
if (target === 'whatsapp') {
return `https://wa.me/?text=${encodeURIComponent(`${text} ${url}`)}`;
}
return '';
};

278
src/utils/solver.js Normal file
View File

@@ -0,0 +1,278 @@
/**
* Represents the state of a cell in the solver.
* -1: Unknown
* 0: Empty
* 1: Filled
*/
/**
* Solves a single line (row or column) based on hints and current knowledge.
* Uses the "Left-Right Overlap" algorithm to find common filled cells.
* Also identifies definitely empty cells (reachable by no block).
*
* @param {number[]} currentLine - Array of -1, 0, 1
* @param {number[]} hints - Array of block lengths
* @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles)
*/
function solveLine(currentLine, hints) {
const length = currentLine.length;
// If no hints, all must be empty
if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) {
return Array(length).fill(0);
}
// Helper to check if a block can be placed at start index
const canPlace = (line, start, blockSize) => {
if (start + blockSize > line.length) return false;
// Check if any cell in block is 0 (Empty) -> Invalid
for (let i = start; i < start + blockSize; i++) {
if (line[i] === 0) return false;
}
// Check boundaries (must be separated by empty or edge)
if (start > 0 && line[start - 1] === 1) return false;
if (start + blockSize < line.length && line[start + blockSize] === 1) return false;
return true;
};
// 1. Calculate Left-Most Positions
const leftPositions = [];
let currentIdx = 0;
for (let hIndex = 0; hIndex < hints.length; hIndex++) {
const block = hints[hIndex];
// Find first valid position
while (currentIdx <= length - block) {
if (canPlace(currentLine, currentIdx, block)) {
// Verify we can fit remaining blocks
// Simple heuristic: do we have enough space?
// A full recursive check is better but slower.
// For "Logical Solver" we assume valid placement is possible if we respect current constraints.
// However, strictly, we need to know if there is *any* valid arrangement starting here.
// Let's use a recursive check with memoization for "can fit rest".
if (canFitRest(currentLine, currentIdx + block + 1, hints, hIndex + 1)) {
leftPositions.push(currentIdx);
currentIdx += block + 1; // Move past this block + 1 space
break;
}
}
currentIdx++;
}
if (leftPositions.length <= hIndex) return null; // Impossible
}
// 2. Calculate Right-Most Positions (by reversing line and hints)
// This is symmetrical to Left-Most.
// Instead of implementing reverse logic, we can just reverse inputs, run left-most, and reverse back.
// But we need to respect the "currentLine" constraints which might be asymmetric.
// Actually, "Right-Most" is just "Left-Most" on the reversed grid.
const reversedLine = [...currentLine].reverse();
const reversedHints = [...hints].reverse();
const rightPositionsReversed = [];
currentIdx = 0;
for (let hIndex = 0; hIndex < reversedHints.length; hIndex++) {
const block = reversedHints[hIndex];
while (currentIdx <= length - block) {
if (canPlace(reversedLine, currentIdx, block)) {
if (canFitRest(reversedLine, currentIdx + block + 1, reversedHints, hIndex + 1)) {
rightPositionsReversed.push(currentIdx);
currentIdx += block + 1;
break;
}
}
currentIdx++;
}
if (rightPositionsReversed.length <= hIndex) return null;
}
// Convert reversed positions to actual indices
// index in reversed = length - 1 - (original_index + block_size - 1)
// original_start = length - 1 - (reversed_start + block_size - 1) = length - reversed_start - block_size
const rightPositions = rightPositionsReversed.map((rStart, i) => {
const block = reversedHints[i];
return length - rStart - block;
}).reverse();
// 3. Intersect
const newLine = [...currentLine];
// Fill intersection
for (let i = 0; i < hints.length; i++) {
const l = leftPositions[i];
const r = rightPositions[i];
const block = hints[i];
// If overlap exists: [r, l + block - 1]
// Example: Block 5. Left: 2, Right: 4.
// Left: ..XXXXX...
// Right: ....XXXXX.
// Overlap: ..XXX...
// Indices: max(l, r) to min(l+block, r+block) - 1 ?
// Range is [r, l + block - 1] (inclusive)
if (r < l + block) {
for (let k = r; k < l + block; k++) {
newLine[k] = 1;
}
}
}
// Determine Empty cells?
// A cell is empty if it is not covered by ANY block in ANY valid configuration.
// This is harder with just L/R limits.
// However, we can use the "Simple Glue" logic:
// If a cell is outside the range [LeftLimit[i], RightLimit[i] + block] for ALL i, it's empty.
// Wait, indices are not strictly partitioned. Block 1 could be at 0 or 10.
// But logic dictates order.
// Range of block i is [LeftPositions[i], RightPositions[i] + hints[i]].
// If a cell k is not in ANY of these ranges, it is 0.
// Mask of possible filled cells
const possibleFilled = Array(length).fill(false);
for (let i = 0; i < hints.length; i++) {
for (let k = leftPositions[i]; k < rightPositions[i] + hints[i]; k++) {
possibleFilled[k] = true;
}
}
for (let k = 0; k < length; k++) {
if (!possibleFilled[k]) {
newLine[k] = 0;
}
}
return newLine;
}
// Memoized helper for checking if hints fit
const memo = new Map();
function canFitRest(line, startIndex, hints, hintIndex) {
// Optimization: If hints are empty, we just need to check if remaining line has no '1's
if (hintIndex >= hints.length) {
for (let i = startIndex; i < line.length; i++) {
if (line[i] === 1) return false;
}
return true;
}
// Key for memoization (primitive approach)
// In a full solver, we'd pass a cache. For single line, maybe overkill, but safe.
// let key = `${startIndex}-${hintIndex}`;
// Skipping memo for now as line lengths are small (<80) and recursion depth is low.
const remainingLen = line.length - startIndex;
// Min space needed: sum of hints + (hints.length - 1) spaces
// Calculate lazily or precalc?
let minSpace = 0;
for(let i=hintIndex; i<hints.length; i++) minSpace += hints[i] + (i < hints.length - 1 ? 1 : 0);
if (remainingLen < minSpace) return false;
const block = hints[hintIndex];
// Try to find *any* valid placement for this block
// We only need ONE valid path to return true.
for (let i = startIndex; i <= line.length - minSpace; i++) { // Optimization on upper bound?
// Check placement
let valid = true;
// Block
for (let k = 0; k < block; k++) {
if (line[i+k] === 0) { valid = false; break; }
}
if (!valid) continue;
// Boundary before (checked by loop start usually, but strictly:
if (i > 0 && line[i-1] === 1) valid = false; // Should have been handled by caller or skip
// Wait, the caller (loop) iterates i.
// If i > startIndex, we implied space at i-1.
// If line[i-1] is 1, we can't place here if we skipped it.
// Actually, if we skip a '1', that's invalid.
// So we can't just skip '1's.
// Correct logic:
// We iterate i. If we pass a '1' at index < i, that 1 is orphaned -> Invalid path.
// So we can only scan forward as long as we don't skip a '1'.
let skippedOne = false;
for (let x = startIndex; x < i; x++) {
if (line[x] === 1) { skippedOne = true; break; }
}
if (skippedOne) break; // Cannot go further right, we left a 1 behind.
// Boundary after
if (i + block < line.length && line[i+block] === 1) valid = false;
if (valid) {
// Recurse
if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) return true;
}
}
return false;
}
/**
* Solves the puzzle using logical iteration.
* @param {number[][]} rowHints
* @param {number[][]} colHints
* @returns {object} { solvedGrid: number[][], percentSolved: number }
*/
export function solvePuzzle(rowHints, colHints) {
const rows = rowHints.length;
const cols = colHints.length;
// Initialize grid with -1
let grid = Array(rows).fill(null).map(() => Array(cols).fill(-1));
let changed = true;
let iterations = 0;
const MAX_ITER = 100; // Safety break
while (changed && iterations < MAX_ITER) {
changed = false;
iterations++;
// Rows
for (let r = 0; r < rows; r++) {
const newLine = solveLine(grid[r], rowHints[r]);
if (newLine) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] !== newLine[c]) {
grid[r][c] = newLine[c];
changed = true;
}
}
}
}
// Cols
for (let c = 0; c < cols; c++) {
const currentCol = grid.map(row => row[c]);
const newCol = solveLine(currentCol, colHints[c]);
if (newCol) {
for (let r = 0; r < rows; r++) {
if (grid[r][c] !== newCol[r]) {
grid[r][c] = newCol[r];
changed = true;
}
}
}
}
}
// Calculate solved %
let solvedCount = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] !== -1) solvedCount++;
}
}
return {
solvedGrid: grid,
percentSolved: (solvedCount / (rows * cols)) * 100
};
}

5
src/utils/timeUtils.js Normal file
View File

@@ -0,0 +1,5 @@
export function formatTime(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}