82 Commits
main ... v1.9.0

Author SHA1 Message Date
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
0b8dcacd18 1.6.3 2026-02-11 02:16:01 +01:00
4ef4f2b251 fix: solver logic and feat: save custom game settings 2026-02-11 02:15:59 +01:00
17d8cbfedd 1.6.2 2026-02-11 01:20:38 +01:00
a41e337c43 fix: improve solver gap enforcement and overlapping block detection 2026-02-11 01:20:29 +01:00
1f1de61044 1.6.1 2026-02-11 01:02:00 +01:00
7f22aa9198 fix: enforce gap between blocks in solver logic 2026-02-11 01:01:53 +01:00
133a676682 1.6.0 2026-02-11 00:47:20 +01:00
30318fafaf feat: improve victory sound effect 2026-02-11 00:47:07 +01:00
5549e24c17 1.5.0 2026-02-11 00:27:50 +01:00
ca3193d07e feat: display hint usage percentage in win screen 2026-02-11 00:27:43 +01:00
41a36768cd 1.4.0 2026-02-11 00:18:37 +01:00
315fb29eac refactor: use inline SVGs and compact download button in WinModal 2026-02-11 00:18:32 +01:00
395e9caff4 1.3.0 2026-02-10 23:59:38 +01:00
e1c73181d4 feat: improve share buttons functionality with hybrid approach 2026-02-10 23:59:32 +01:00
874e35bba3 1.2.1 2026-02-10 23:44:03 +01:00
69b04d3336 fix: add missing translations for Arabic and other languages 2026-02-10 23:43:58 +01:00
c5b212234a 1.2.0 2026-02-10 23:13:21 +01:00
8e0ddf3a72 refactor: optimize solver to use pure logic without guessing 2026-02-10 23:13:14 +01:00
bfb24cfb03 1.1.0 2026-02-10 23:00:49 +01:00
8d3bde8d38 feat: add difficulty and dirty flag to result image 2026-02-10 23:00:43 +01:00
d25fa67100 1.0.7 2026-02-10 22:35:34 +01:00
c7834bd8bf feat: enhance custom game mode with fill rate slider and difficulty indicator 2026-02-10 22:35:28 +01:00
7d405ef0f6 1.0.6 2026-02-10 21:56:21 +01:00
431b534477 fix: update PWA translations for multiple languages 2026-02-10 21:56:21 +01:00
ebf9030185 1.0.5 2026-02-10 21:35:24 +01:00
8f52f5daa5 fix: correct PWA translations for all languages 2026-02-10 21:35:24 +01:00
2dc68ab8d0 1.0.4 2026-02-10 21:28:58 +01:00
993ced424e feat: add PWA translations for all languages 2026-02-10 21:28:58 +01:00
1b0b6a671a 1.0.3 2026-02-10 21:16:28 +01:00
e39ac9a794 feat: implement PWA update prompt and manual reload 2026-02-10 21:16:28 +01:00
81ed97b263 1.0.2 2026-02-10 21:06:59 +01:00
61359e6665 style: fix footer position and visibility 2026-02-10 21:06:59 +01:00
8f8f6a6e8e fix: update guide panel colors to be readable in light mode 2026-02-10 21:00:21 +01:00
a02c17e462 fix: use theme variables for install banner readability 2026-02-10 20:56:04 +01:00
490ca6b4a5 revert: remove css filter inversion for share icons 2026-02-10 20:53:18 +01:00
10ed00b7b6 fix: refine share icon inversion to target only specific elements 2026-02-10 20:51:46 +01:00
a335adeca8 style: scope share icon color inversion to win modal only 2026-02-10 20:49:37 +01:00
72adab61c7 fix: ensure share icons are visible in light mode by inverting colors 2026-02-10 20:44:35 +01:00
2c892f3adc chore: bump version to 1.0.1 2026-02-10 17:43:17 +01:00
b6b289cd57 chore: enhance PWA cache invalidation logic 2026-02-10 17:35:05 +01:00
ea2e3c573b feat: display app version from package.json in footer 2026-02-10 17:24:19 +01:00
cf2e9de56d refactor: remove all guide usage checks from win modal logic 2026-02-10 17:18:17 +01:00
4313831e84 75% filled blocks 2026-02-10 09:54:56 +01:00
60ef352cda 10% more filled blocks 2026-02-10 09:39:05 +01:00
91c873ef97 feat: always show share buttons in win modal regardless of guide usage 2026-02-10 04:50:13 +01:00
c4764e9505 fix: allow horizontal scroll on desktop for large grids 2026-02-10 03:10:37 +01:00
423258ef17 chore: remove tracked build artifacts from dev-dist 2026-02-10 03:05:46 +01:00
0bd2b905a9 Fix desktop grid scroll issue: remove width limit and hide scrollbar 2026-02-10 02:59:10 +01:00
572acc3979 feat: complete translations for new game and guide buttons for all languages 2026-02-10 02:43:19 +01:00
7256809f0b fix: japanese translation for new game and guide buttons 2026-02-10 02:35:26 +01:00
9f89fe4340 feat: disable win fanfare and sharing if guide was used 2026-02-10 02:30:58 +01:00
7610a4a523 feat: localize new game and guide buttons 2026-02-10 01:54:34 +01:00
285c486c6e chore: add i18n update script 2026-02-10 01:40:09 +01:00
8a97cc5d0d feat: add traditional chinese language support 2026-02-10 01:37:50 +01:00
c287523fa5 feat: localize new game and guide buttons 2026-02-10 01:20:37 +01:00
eb6b69134f fix: persist language selection and enforce search keys 2026-02-10 00:54:29 +01:00
4d50eb97eb Fix Undo icon and improve mobile UX 2026-02-10 00:08:23 +01:00
a1df95d3d4 fix 2026-02-09 23:35:21 +01:00
4a7c088776 fix: update flag mappings and ignore dev artifacts 2026-02-09 23:25:50 +01:00
6f3ed143e5 feat: add system theme option to navbar 2026-02-09 23:17:52 +01:00
c5f9da81a9 Merge branch 'feature/upgrades' into main 2026-02-09 22:58:42 +01:00
0decf2324c Add vitest setup and puzzleUtils tests 2026-02-09 22:51:00 +01:00
96999f740c Fix flag display priority to prefer inline SVG when file is missing 2026-02-09 22:21:10 +01:00
4982e6ed49 Add Batch 3 and Batch 4 translations and improve language detection 2026-02-09 22:10:03 +01:00
35 changed files with 9497 additions and 3801 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

@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@@ -1,92 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.eh0stsihuc8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

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
}
]

2050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.0.0", "version": "1.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"fireworks-js": "^2.10.8", "fireworks-js": "^2.10.8",
@@ -16,7 +17,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/test-utils": "^2.4.6",
"jsdom": "^28.0.0",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-pwa": "^0.20.5" "vite-plugin-pwa": "^0.20.5",
"vitest": "^4.0.18"
} }
} }

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,12 +8,15 @@ 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';
// Main App Entry // Main App Entry
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);
@@ -21,6 +24,7 @@ const installDismissed = ref(false);
const isCoarsePointer = ref(false); const isCoarsePointer = ref(false);
const isStandalone = ref(false); const isStandalone = ref(false);
const themePreference = ref('system'); const themePreference = ref('system');
const appVersion = __APP_VERSION__;
let displayModeMedia = null; let displayModeMedia = null;
let prefersColorSchemeMedia = null; let prefersColorSchemeMedia = null;
@@ -164,10 +168,16 @@ onUnmounted(() => {
</section> </section>
</div> </div>
<footer class="app-version">
v{{ appVersion }}
</footer>
<!-- 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 />
</Teleport> </Teleport>
</main> </main>
</template> </template>
@@ -183,8 +193,8 @@ onUnmounted(() => {
} }
.install-banner { .install-banner {
background: rgba(0, 242, 254, 0.1); background: var(--banner-bg);
border: 1px solid var(--primary-neon); border: 1px solid var(--banner-border);
border-radius: 8px; border-radius: 8px;
padding: 10px 20px; padding: 10px 20px;
display: flex; display: flex;
@@ -193,10 +203,11 @@ onUnmounted(() => {
width: 90%; width: 90%;
max-width: 600px; max-width: 600px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: var(--banner-shadow);
} }
.install-text { .install-text {
color: #fff; color: var(--text-color);
} }
.install-actions { .install-actions {
@@ -208,7 +219,7 @@ onUnmounted(() => {
.install-close { .install-close {
background: transparent; background: transparent;
border: none; border: none;
color: #aaa; color: var(--text-muted);
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
} }
@@ -238,4 +249,20 @@ onUnmounted(() => {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.app-version {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
text-align: center;
padding: 10px;
font-size: 0.8rem;
color: var(--text-muted);
opacity: 0.8;
background: var(--panel-bg);
backdrop-filter: blur(5px);
border-top: 1px solid var(--panel-border);
z-index: 90;
}
</style> </style>

View File

@@ -34,35 +34,30 @@ const clearLongPress = () => {
const handlePointerDown = (e) => { const handlePointerDown = (e) => {
if (e.pointerType === 'mouse') { if (e.pointerType === 'mouse') {
if (e.button === 0) emit('start-drag', props.r, props.c, false); if (e.button === 0) emit('start-drag', props.r, props.c, false, false);
if (e.button === 2) emit('start-drag', props.r, props.c, true); if (e.button === 2) emit('start-drag', props.r, props.c, true, false);
return; return;
} }
longPressTriggered = false;
clearLongPress(); // Touch logic
longPressTimer = setTimeout(() => { const now = Date.now();
longPressTriggered = true; if (now - lastTap < 300) {
emit('start-drag', props.r, props.c, true); // Double tap -> X (Force)
}, 450); emit('start-drag', props.r, props.c, true, true);
lastTap = 0;
} else {
// Single tap / Start drag -> Fill
emit('start-drag', props.r, props.c, false, false);
lastTap = now;
}
}; };
const handlePointerUp = (e) => { const handlePointerUp = (e) => {
if (e.pointerType === 'mouse') return; // Handled in pointerdown
clearLongPress();
if (longPressTriggered) return;
const now = Date.now();
const isDoubleTap = now - lastTap < 300;
lastTap = now;
if (isDoubleTap) {
emit('start-drag', props.r, props.c, true);
} else {
emit('start-drag', props.r, props.c, false);
}
}; };
const handlePointerCancel = (e) => { const handlePointerCancel = (e) => {
if (e.pointerType === 'mouse') return; // Handled in pointerdown
clearLongPress();
}; };
</script> </script>

View File

@@ -1,14 +1,201 @@
<script setup> <script setup>
import { ref } 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 { 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 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(() => {
const savedSize = localStorage.getItem('nonograms_custom_size');
if (savedSize && !isNaN(savedSize)) {
customSize.value = Math.max(5, Math.min(80, Number(savedSize)));
}
const savedFillRate = localStorage.getItem('nonograms_custom_fill_rate');
if (savedFillRate && !isNaN(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) => {
localStorage.setItem('nonograms_custom_size', newVal);
});
watch(fillRate, (newVal) => {
localStorage.setItem('nonograms_custom_fill_rate', newVal);
});
const snapToStep = (value, step) => { const snapToStep = (value, step) => {
const rounded = Math.round(value / step) * step; const rounded = Math.round(value / step) * step;
@@ -19,6 +206,20 @@ const handleSnap = () => {
customSize.value = snapToStep(Number(customSize.value), 5); customSize.value = snapToStep(Number(customSize.value), 5);
}; };
const difficultyInfo = computed(() => {
return calculateDifficulty(fillRate.value / 100, customSize.value);
});
const difficultyColor = computed(() => {
switch(difficultyInfo.value.level) {
case 'extreme': return '#ff3333';
case 'hardest': return '#ff9933';
case 'harder': return '#ffff33';
case 'easy': return '#33ff33';
default: return '#33ff33';
}
});
const confirm = () => { const confirm = () => {
const size = parseInt(customSize.value); const size = parseInt(customSize.value);
if (isNaN(size) || size < 5 || size > 80) { if (isNaN(size) || size < 5 || size > 80) {
@@ -26,7 +227,7 @@ const confirm = () => {
return; return;
} }
store.initCustomGame(size); store.initCustomGame(size, fillRate.value / 100);
emit('close'); emit('close');
}; };
</script> </script>
@@ -35,8 +236,11 @@ 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="modal-content">
<div class="sliders-section">
<div class="slider-container">
<p>{{ t('custom.prompt') }}</p>
<div class="input-group"> <div class="input-group">
<div class="range-value">{{ customSize }}</div> <div class="range-value">{{ customSize }}</div>
<input <input
@@ -52,6 +256,58 @@ const confirm = () => {
<span>80</span> <span>80</span>
</div> </div>
</div> </div>
</div>
<div class="slider-container">
<p>{{ t('custom.fillRate') }}</p>
<div class="input-group">
<div class="range-value">{{ fillRate }}%</div>
<input
type="range"
v-model="fillRate"
min="10"
max="90"
step="1"
/>
<div class="range-scale">
<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 class="difficulty-indicator">
<div class="label-row">
<div class="label">{{ t('custom.difficulty') }}</div>
<button class="help-btn" @click="emit('open-simulation')" :title="t('custom.simulationHelp') || 'How is this calculated?'">
<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>
<p v-if="errorMsg" class="error">{{ errorMsg }}</p> <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
@@ -82,11 +338,60 @@ 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);
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
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 {
@@ -102,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;
@@ -161,11 +471,107 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.85rem; font-size: 0.85rem;
} }
.difficulty-indicator {
margin: 20px 0 40px 0;
display: flex;
flex-direction: column;
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;
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 {
color: var(--text-color);
}
.difficulty-indicator .value {
font-weight: bold;
text-transform: uppercase;
text-shadow: 0 0 10px currentColor;
transition: color 0.3s ease;
display: inline-block;
min-width: 120px; /* Reserve space for longest text */
text-align: left;
}
.error { .error {
color: #ff4d4d; color: #ff4d4d;
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

@@ -15,6 +15,94 @@ const rowHintsRef = ref(null);
const activeRow = ref(null); const activeRow = ref(null);
const activeCol = ref(null); const activeCol = ref(null);
const isFinePointer = ref(false); const isFinePointer = ref(false);
const scrollWrapper = ref(null);
const scrollTrack = ref(null);
const showScrollbar = ref(false);
const thumbWidth = ref(20);
const thumbLeft = ref(0);
let isDraggingScroll = false;
let dragStartX = 0;
let dragStartLeft = 0;
const checkScroll = () => {
const el = scrollWrapper.value;
if (!el) return;
const sw = el.scrollWidth;
const cw = el.clientWidth;
// Only show custom scrollbar on mobile/tablet (width < 768px) and if content overflows
const isMobile = window.innerWidth <= 768;
showScrollbar.value = isMobile && (sw > cw + 1);
if (showScrollbar.value) {
// Thumb width percentage = (viewport / total) * 100
const ratio = cw / sw;
thumbWidth.value = Math.max(10, ratio * 100);
}
};
const handleScroll = () => {
if (isDraggingScroll) return;
const el = scrollWrapper.value;
if (!el) return;
const sw = el.scrollWidth;
const cw = el.clientWidth;
const sl = el.scrollLeft;
const maxScroll = sw - cw;
if (maxScroll <= 0) return;
// Map scroll position to thumb position (0 to 100 - thumbWidth)
const maxThumb = 100 - thumbWidth.value;
thumbLeft.value = (sl / maxScroll) * maxThumb;
};
const startScrollDrag = (e) => {
isDraggingScroll = true;
dragStartX = e.clientX || e.touches[0].clientX;
dragStartLeft = thumbLeft.value;
document.addEventListener('mousemove', onScrollDrag);
document.addEventListener('mouseup', stopScrollDrag);
document.addEventListener('touchmove', onScrollDrag, { passive: false });
document.addEventListener('touchend', stopScrollDrag);
};
const onScrollDrag = (e) => {
if (!isDraggingScroll || !scrollTrack.value) return;
const clientX = e.clientX || (e.touches ? e.touches[0].clientX : 0);
const deltaX = clientX - dragStartX;
const trackWidth = scrollTrack.value.offsetWidth;
if (trackWidth === 0) return;
// Calculate delta as percentage of track
const deltaPercent = (deltaX / trackWidth) * 100;
// New thumb position
let newLeft = dragStartLeft + deltaPercent;
const maxThumb = 100 - thumbWidth.value;
newLeft = Math.max(0, Math.min(maxThumb, newLeft));
thumbLeft.value = newLeft;
// Sync scroll
const el = scrollWrapper.value;
if (el) {
const sw = el.scrollWidth;
const cw = el.clientWidth;
const maxScroll = sw - cw;
el.scrollLeft = (newLeft / maxThumb) * maxScroll;
}
};
const stopScrollDrag = () => {
isDraggingScroll = false;
document.removeEventListener('mousemove', onScrollDrag);
document.removeEventListener('mouseup', stopScrollDrag);
document.removeEventListener('touchmove', onScrollDrag);
document.removeEventListener('touchend', stopScrollDrag);
};
const getRowHintsWidth = () => { const getRowHintsWidth = () => {
const el = rowHintsRef.value?.$el; const el = rowHintsRef.value?.$el;
@@ -23,17 +111,27 @@ const getRowHintsWidth = () => {
}; };
const computeCellSize = () => { const computeCellSize = () => {
const vw = Math.min(window.innerWidth, 900);
const rootStyles = getComputedStyle(document.documentElement); const rootStyles = getComputedStyle(document.documentElement);
const hintWidth = getRowHintsWidth(); const hintWidth = getRowHintsWidth();
const gapRaw = rootStyles.getPropertyValue('--gap-size') || '2px'; const gapRaw = rootStyles.getPropertyValue('--gap-size') || '2px';
const gridPadRaw = rootStyles.getPropertyValue('--grid-padding') || '5px'; const gridPadRaw = rootStyles.getPropertyValue('--grid-padding') || '5px';
const gap = parseFloat(gapRaw); const gap = parseFloat(gapRaw);
const gridPad = parseFloat(gridPadRaw); const gridPad = parseFloat(gridPadRaw);
const bodyStyles = getComputedStyle(document.body);
const bodyPadding = parseFloat(bodyStyles.paddingLeft) + parseFloat(bodyStyles.paddingRight); let containerWidth;
const availableForGrid = vw - bodyPadding - hintWidth; if (scrollWrapper.value) {
containerWidth = scrollWrapper.value.clientWidth;
} else {
// Fallback if wrapper not ready: window width minus estimated padding
// Body padding (40) + Layout padding (40) = 80
containerWidth = window.innerWidth - 80;
}
// Ensure we don't have negative space
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)); cellSize.value = Math.max(18, Math.min(36, size));
}; };
@@ -74,6 +172,7 @@ onMounted(() => {
}); });
isFinePointer.value = window.matchMedia('(pointer: fine)').matches; isFinePointer.value = window.matchMedia('(pointer: fine)').matches;
window.addEventListener('resize', computeCellSize); window.addEventListener('resize', computeCellSize);
window.addEventListener('resize', checkScroll);
window.addEventListener('mouseup', handleGlobalMouseUp); window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('pointerup', handleGlobalPointerUp); window.addEventListener('pointerup', handleGlobalPointerUp);
window.addEventListener('touchend', handleGlobalPointerUp, { passive: true }); window.addEventListener('touchend', handleGlobalPointerUp, { passive: true });
@@ -81,6 +180,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', computeCellSize); window.removeEventListener('resize', computeCellSize);
window.removeEventListener('resize', checkScroll);
window.removeEventListener('mouseup', handleGlobalMouseUp); window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('pointerup', handleGlobalPointerUp); window.removeEventListener('pointerup', handleGlobalPointerUp);
window.removeEventListener('touchend', handleGlobalPointerUp); window.removeEventListener('touchend', handleGlobalPointerUp);
@@ -89,11 +189,12 @@ onUnmounted(() => {
watch(() => store.size, async () => { watch(() => store.size, async () => {
await nextTick(); await nextTick();
computeCellSize(); computeCellSize();
checkScroll();
}); });
</script> </script>
<template> <template>
<div class="game-board-wrapper"> <div class="game-board-wrapper" ref="scrollWrapper" @scroll="handleScroll">
<div class="game-container" :style="{ '--cell-size': `${cellSize}px` }"> <div class="game-container" :style="{ '--cell-size': `${cellSize}px` }">
<div class="corner-spacer"></div> <div class="corner-spacer"></div>
@@ -131,13 +232,39 @@ watch(() => store.size, async () => {
</div> </div>
</div> </div>
</div> </div>
<Teleport to="body">
<div v-if="showScrollbar" class="fixed-scroll-bar">
<div class="fixed-scroll-track" ref="scrollTrack" @pointerdown="startScrollDrag">
<div
class="fixed-scroll-thumb"
:style="{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }"
></div>
</div>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
.game-board-wrapper { .game-board-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
overflow-x: auto;
width: 100%;
scrollbar-width: none; /* Hide default scrollbar */
}
/* Desktop: Remove scroll behavior to ensure full grid visibility */
@media (min-width: 769px) {
.game-board-wrapper {
overflow-x: auto; /* Allow scrolling if grid is too large (e.g. 80x80) */
align-items: center; /* Center the grid on desktop */
}
}
.game-board-wrapper::-webkit-scrollbar {
display: none;
} }
.game-container { .game-container {
@@ -145,17 +272,24 @@ watch(() => store.size, async () => {
grid-template-columns: auto auto; grid-template-columns: auto auto;
grid-template-rows: auto auto; grid-template-rows: auto auto;
gap: 0; gap: 0;
padding: 20px; padding: 0;
background: rgba(0, 0, 0, 0.2); background: transparent;
border-radius: 16px; box-shadow: none;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.3);
margin-top: 10px; margin-top: 10px;
margin-left: auto;
margin-right: auto;
position: relative;
} }
.corner-spacer { .corner-spacer {
height: auto; /* Adapts to Col Hints height */ height: auto; /* Adapts to Col Hints height */
} }
/* Row Hints */
.game-container > :nth-child(3) {
/* No special styles */
}
.grid { .grid {
display: grid; display: grid;
gap: var(--gap-size); gap: var(--gap-size);

View File

@@ -35,8 +35,8 @@ const { t } = useI18n();
<style scoped> <style scoped>
.guide-panel { .guide-panel {
background: rgba(0, 0, 0, 0.25); background: var(--panel-bg);
border: 1px solid rgba(0, 242, 254, 0.3); border: 1px solid var(--panel-border);
border-radius: 12px; border-radius: 12px;
padding: 15px; padding: 15px;
margin-bottom: 20px; margin-bottom: 20px;
@@ -46,6 +46,7 @@ const { t } = useI18n();
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
box-shadow: var(--panel-shadow);
} }
.status-text { .status-text {
@@ -53,7 +54,7 @@ const { t } = useI18n();
text-align: center; text-align: center;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 0.9rem; font-size: 0.9rem;
color: #ccc; color: var(--text-muted);
min-height: 20px; min-height: 20px;
font-style: italic; font-style: italic;
} }

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp } from 'lucide-vue-next'; import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor } from 'lucide-vue-next';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n(); const { t, locale, setLocale, locales } = useI18n();
@@ -20,6 +20,7 @@ const searchTerm = ref('');
const langToCountry = { const langToCountry = {
en: 'gb', en: 'gb',
zh: 'cn', zh: 'cn',
'zh-hant': 'tw',
hi: 'in', hi: 'in',
es: 'es', es: 'es',
fr: 'fr', fr: 'fr',
@@ -61,7 +62,59 @@ const langToCountry = {
gl: 'es-ga', gl: 'es-ga',
cy: 'gb-wls', cy: 'gb-wls',
gd: 'gb-sct', gd: 'gb-sct',
eu: 'es-pv' eu: 'es-pv',
af: 'za',
am: 'et',
hy: 'am',
az: 'az',
my: 'mm',
km: 'kh',
ceb: 'ph',
fa: 'ir',
gu: 'in',
ht: 'ht',
he: 'il',
ig: 'ng',
ilo: 'ph',
id: 'id',
ja: 'jp',
jv: 'id',
kn: 'in',
kk: 'kz',
rw: 'rw',
rn: 'bi',
ko: 'kr',
ku: 'tr',
ckb: 'iq',
ky: 'kg',
lo: 'la',
ms: 'my',
mr: 'in',
mn: 'mn',
ne: 'np',
om: 'et',
ps: 'af',
pa: 'in',
so: 'so',
sw: 'tz',
tl: 'ph',
ta: 'in',
te: 'in',
th: 'th',
bo: 'cn',
ti: 'er',
uz: 'uz',
vi: 'vn',
wo: 'sn',
yo: 'ng',
'pt-br': 'br',
'pt-pt': 'pt',
'fr-ca': 'ca',
'nl-be': 'be',
'es-es': 'es',
'es-419': 'mx',
'zh-hant': 'tw',
'zh-hans': 'cn'
}; };
const getFlagClass = (code) => { const getFlagClass = (code) => {
@@ -188,7 +241,7 @@ watch(isMobileMenuOpen, (val) => {
<!-- Game Menu --> <!-- Game Menu -->
<div class="nav-dropdown"> <div class="nav-dropdown">
<button class="btn-neon nav-btn" @click.stop="toggleGameMenu"> <button class="btn-neon nav-btn" @click.stop="toggleGameMenu">
<Gamepad2 :size="18" /> NEW GAME <Gamepad2 :size="18" /> {{ t('nav.newGame') }}
</button> </button>
<transition name="slide-fade"> <transition name="slide-fade">
<div v-if="isGameOpen" class="dropdown-menu"> <div v-if="isGameOpen" class="dropdown-menu">
@@ -210,15 +263,18 @@ watch(isMobileMenuOpen, (val) => {
<!-- Theme Menu --> <!-- Theme Menu -->
<div class="nav-dropdown"> <div class="nav-dropdown">
<button class="btn-neon nav-btn" @click.stop="toggleThemeMenu"> <button class="btn-neon nav-btn" @click.stop="toggleThemeMenu">
<Palette :size="18" /> Theme <Palette :size="18" /> {{ t('theme.label') }}
</button> </button>
<transition name="slide-fade"> <transition name="slide-fade">
<div v-if="isThemeOpen" class="dropdown-menu theme-menu"> <div v-if="isThemeOpen" class="dropdown-menu theme-menu">
<button class="dropdown-item" @click="setTheme('system')">
<Monitor :size="16" /> {{ t('theme.system') }}
</button>
<button class="dropdown-item" @click="setTheme('light')"> <button class="dropdown-item" @click="setTheme('light')">
<Sun :size="16" /> Light <Sun :size="16" /> {{ t('theme.light') }}
</button> </button>
<button class="dropdown-item" @click="setTheme('dark')"> <button class="dropdown-item" @click="setTheme('dark')">
<Moon :size="16" /> Dark <Moon :size="16" /> {{ t('theme.dark') }}
</button> </button>
</div> </div>
</transition> </transition>
@@ -226,7 +282,7 @@ watch(isMobileMenuOpen, (val) => {
<!-- Guide Button --> <!-- Guide Button -->
<button class="btn-neon nav-btn" @click="toggleGuide"> <button class="btn-neon nav-btn" @click="toggleGuide">
<CircleHelp :size="18" /> GUIDE <CircleHelp :size="18" /> {{ t('nav.guide') }}
</button> </button>
<!-- Language Menu --> <!-- Language Menu -->
@@ -280,7 +336,7 @@ watch(isMobileMenuOpen, (val) => {
<!-- Mobile Game Menu --> <!-- Mobile Game Menu -->
<div class="mobile-group"> <div class="mobile-group">
<button class="mobile-item-trigger" @click="toggleGameMenu"> <button class="mobile-item-trigger" @click="toggleGameMenu">
<span class="flex-center gap-10"><Gamepad2 :size="20" /> NEW GAME</span> <span class="flex-center gap-10"><Gamepad2 :size="20" /> {{ t('nav.newGame') }}</span>
<component :is="isGameOpen ? ChevronUp : ChevronDown" :size="16" /> <component :is="isGameOpen ? ChevronUp : ChevronDown" :size="16" />
</button> </button>
<div v-if="isGameOpen" class="mobile-sub-menu"> <div v-if="isGameOpen" class="mobile-sub-menu">
@@ -301,15 +357,18 @@ watch(isMobileMenuOpen, (val) => {
<!-- Mobile Theme Menu --> <!-- Mobile Theme Menu -->
<div class="mobile-group"> <div class="mobile-group">
<button class="mobile-item-trigger" @click="toggleThemeMenu"> <button class="mobile-item-trigger" @click="toggleThemeMenu">
<span class="flex-center gap-10"><Palette :size="20" /> Theme</span> <span class="flex-center gap-10"><Palette :size="20" /> {{ t('theme.label') }}</span>
<component :is="isThemeOpen ? ChevronUp : ChevronDown" :size="16" /> <component :is="isThemeOpen ? ChevronUp : ChevronDown" :size="16" />
</button> </button>
<div v-if="isThemeOpen" class="mobile-sub-menu"> <div v-if="isThemeOpen" class="mobile-sub-menu">
<button class="mobile-sub-item" @click="setTheme('system')">
<Monitor :size="16" /> {{ t('theme.system') }}
</button>
<button class="mobile-sub-item" @click="setTheme('light')"> <button class="mobile-sub-item" @click="setTheme('light')">
<Sun :size="16" /> Light <Sun :size="16" /> {{ t('theme.light') }}
</button> </button>
<button class="mobile-sub-item" @click="setTheme('dark')"> <button class="mobile-sub-item" @click="setTheme('dark')">
<Moon :size="16" /> Dark <Moon :size="16" /> {{ t('theme.dark') }}
</button> </button>
</div> </div>
</div> </div>
@@ -317,7 +376,7 @@ watch(isMobileMenuOpen, (val) => {
<!-- Mobile Guide --> <!-- Mobile Guide -->
<div class="mobile-group"> <div class="mobile-group">
<button class="mobile-item-trigger" @click="toggleGuide"> <button class="mobile-item-trigger" @click="toggleGuide">
<span class="flex-center gap-10"><CircleHelp :size="20" /> GUIDE</span> <span class="flex-center gap-10"><CircleHelp :size="20" /> {{ t('nav.guide') }}</span>
</button> </button>
</div> </div>
@@ -328,7 +387,7 @@ watch(isMobileMenuOpen, (val) => {
<span class="lang-flag-current mobile-flag"> <span class="lang-flag-current mobile-flag">
<span :class="getFlagClass(locale)"></span> <span :class="getFlagClass(locale)"></span>
</span> </span>
Language {{ t('language.label') }}
</span> </span>
<component :is="isLangOpen ? ChevronUp : ChevronDown" :size="16" /> <component :is="isLangOpen ? ChevronUp : ChevronDown" :size="16" />
</button> </button>
@@ -549,14 +608,14 @@ watch(isMobileMenuOpen, (val) => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
background: rgba(10, 10, 25, 0.98); background: rgba(10, 10, 25, 0.98);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
z-index: 10000; z-index: 10000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px; padding: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -564,22 +623,24 @@ watch(isMobileMenuOpen, (val) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 40px; padding: 10px 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(0, 242, 254, 0.2); border-bottom: 1px solid rgba(0, 242, 254, 0.2);
flex-shrink: 0;
} }
.mobile-title { .mobile-title {
font-size: 1.5rem; font-size: 1.8rem;
color: var(--primary-neon); color: var(--primary-neon);
margin: 0; margin: 0;
letter-spacing: 2px; letter-spacing: 3px;
font-weight: 300;
} }
.mobile-menu-items { .mobile-menu-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 15px;
padding: 20px;
} }
.mobile-group { .mobile-group {

View File

@@ -0,0 +1,110 @@
<script setup>
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
const {
offlineReady,
needRefresh,
updateServiceWorker,
} = useRegisterSW()
const close = async () => {
offlineReady.value = false
needRefresh.value = false
}
</script>
<template>
<div
v-if="offlineReady || needRefresh"
class="pwa-toast"
role="alert"
>
<div class="message">
<span v-if="offlineReady">
{{ t('pwa.offlineReady') }}
</span>
<span v-else>
{{ t('pwa.newContent') }}
</span>
</div>
<div class="buttons">
<button v-if="needRefresh" class="btn-neon small" @click="updateServiceWorker()">
{{ t('pwa.reload') }}
</button>
<button class="close-btn" @click="close">
{{ t('pwa.close') }}
</button>
</div>
</div>
</template>
<style scoped>
.pwa-toast {
position: fixed;
right: 0;
bottom: 60px; /* Above the footer */
margin: 16px;
padding: 15px;
border: 1px solid var(--banner-border);
background: var(--banner-bg);
border-radius: 12px;
z-index: 2000;
text-align: left;
box-shadow: var(--banner-shadow);
display: flex;
flex-direction: column;
gap: 10px;
backdrop-filter: blur(10px);
color: var(--text-color);
max-width: 320px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.message {
font-size: 0.95rem;
line-height: 1.4;
}
.buttons {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
margin-top: 5px;
}
.close-btn {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-muted);
padding: 6px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.close-btn:hover {
border-color: var(--text-color);
color: var(--text-color);
}
.btn-neon.small {
padding: 6px 16px;
font-size: 0.85rem;
}
</style>

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

@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useTimer } from '@/composables/useTimer'; import { useTimer } from '@/composables/useTimer';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { RotateCcw, RefreshCw, Eye } from 'lucide-vue-next'; import { RotateCcw, RefreshCw, Eye, Undo } from 'lucide-vue-next';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { formatTime } = useTimer(); const { formatTime } = useTimer();
@@ -45,7 +45,7 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
</button> </button>
<div class="action-separator"></div> <div class="action-separator"></div>
<button class="action-btn" @click="store.undo" :title="t('actions.undo')"> <button class="action-btn" @click="store.undo" :title="t('actions.undo')">
<RotateCcw :size="20" /> <Undo :size="20" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -4,9 +4,8 @@ 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 { useTimer } from '@/composables/useTimer';
import xIcon from '@/assets/brands/x.svg'; import { Download, FileCode } from 'lucide-vue-next';
import facebookIcon from '@/assets/brands/facebook.svg'; import { calculateDifficulty } from '@/utils/puzzleUtils';
import whatsappIcon from '@/assets/brands/whatsapp.svg';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -42,30 +41,43 @@ const playFanfare = async () => {
} }
} }
masterGain = audioContext.createGain(); masterGain = audioContext.createGain();
masterGain.gain.value = 0.18; masterGain.gain.value = 0.25; // Slightly louder but softer tone
masterGain.connect(audioContext.destination); masterGain.connect(audioContext.destination);
const notes = [
{ time: 0.0, dur: 0.18, freqs: [523.25, 659.25, 783.99] },
{ time: 0.2, dur: 0.18, freqs: [587.33, 740.0, 880.0] },
{ time: 0.4, dur: 0.22, freqs: [659.25, 830.61, 987.77] },
{ time: 0.7, dur: 0.35, freqs: [698.46, 880.0, 1046.5] }
];
const now = audioContext.currentTime; const now = audioContext.currentTime;
notes.forEach(({ time, dur, freqs }) => {
freqs.forEach((freq) => { const playNote = (freq, startTime, duration) => {
const osc = audioContext.createOscillator(); const osc = audioContext.createOscillator();
const gain = audioContext.createGain(); const gain = audioContext.createGain();
osc.type = 'triangle';
// Mix of sine and triangle for a bell-like quality
osc.type = 'sine';
osc.frequency.value = freq; osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, now + time);
gain.gain.linearRampToValueAtTime(0.8, now + time + 0.02); // Envelope for elegant bell/chime sound
gain.gain.exponentialRampToValueAtTime(0.0001, now + time + dur); 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); osc.connect(gain);
gain.connect(masterGain); gain.connect(masterGain);
osc.start(now + time);
osc.stop(now + time + dur + 0.05); 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 = () => {
@@ -88,8 +100,9 @@ const buildShareCanvas = () => {
const padding = 28; const padding = 28;
const headerHeight = 64; const headerHeight = 64;
const footerHeight = 28; const footerHeight = 28;
const infoHeight = 40; // New space for difficulty/guide info
const width = boardSize + padding * 2; const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight; const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
const scale = window.devicePixelRatio || 1; const scale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = width * scale; canvas.width = width * scale;
@@ -109,6 +122,24 @@ const buildShareCanvas = () => {
ctx.fillText(t('app.title'), padding, padding + 10); ctx.fillText(t('app.title'), padding, padding + 10);
ctx.font = '600 16px "Segoe UI", sans-serif'; ctx.font = '600 16px "Segoe UI", sans-serif';
ctx.fillText(`${t('win.time')} ${formattedTime.value}`, padding, padding + 34); 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 gridX = padding;
const gridY = padding + headerHeight; const gridY = padding + headerHeight;
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
@@ -152,12 +183,144 @@ const buildShareCanvas = () => {
} }
} }
} }
// 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.fillStyle = 'rgba(255, 255, 255, 0.75)';
ctx.font = '500 14px "Segoe UI", sans-serif'; ctx.font = '500 14px "Segoe UI", sans-serif';
ctx.fillText(appUrl, padding, height - padding + 6); ctx.fillText(appUrl, padding, height - padding + 6);
return canvas; 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 diffInfo = calculateDifficulty(store.currentDensity, store.size);
const difficultyKey = diffInfo.level;
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 canvasToBlob = (canvas) => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png'));
const createShareBlob = async () => { const createShareBlob = async () => {
@@ -166,6 +329,20 @@ const createShareBlob = async () => {
return canvasToBlob(canvas); return canvasToBlob(canvas);
}; };
const downloadShareSVG = () => {
const svgString = buildShareSVG();
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(); const blob = await createShareBlob();
if (!blob) return; if (!blob) return;
@@ -183,7 +360,7 @@ const buildShareUrl = (target, text, url) => {
const encodedText = encodeURIComponent(text); const encodedText = encodeURIComponent(text);
const encodedUrl = encodeURIComponent(url); const encodedUrl = encodeURIComponent(url);
if (target === 'x') { if (target === 'x') {
return `https://twitter.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`; return `https://x.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
} }
if (target === 'facebook') { if (target === 'facebook') {
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`; return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`;
@@ -197,13 +374,18 @@ const buildShareUrl = (target, text, url) => {
const shareTo = async (target) => { const shareTo = async (target) => {
if (shareInProgress.value) return; if (shareInProgress.value) return;
shareInProgress.value = true; shareInProgress.value = true;
try {
const blob = await createShareBlob();
if (!blob) return;
const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' });
const text = shareText.value; const text = shareText.value;
const url = window.location.href; const url = window.location.href;
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { const shareUrl = buildShareUrl(target, text, url);
try {
// Try native share first if available (supports images)
if (navigator.share && navigator.canShare) {
const blob = await createShareBlob();
if (blob) {
const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
await navigator.share({ await navigator.share({
files: [file], files: [file],
text, text,
@@ -212,14 +394,28 @@ const shareTo = async (target) => {
}); });
return; return;
} }
await downloadShareImage();
const shareUrl = buildShareUrl(target, text, url);
if (shareUrl) {
window.open(shareUrl, '_blank', 'noopener');
} }
}
} catch (error) {
if (error.name === 'AbortError') {
return; // User cancelled native share, do nothing
}
// Other errors -> fall through to fallback
} finally { } finally {
shareInProgress.value = false; shareInProgress.value = false;
} }
// 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) {
window.open(shareUrl, '_blank', 'noopener');
}
// Trigger download as "screenshot support"
downloadShareImage();
}; };
onMounted(() => { onMounted(() => {
@@ -279,19 +475,27 @@ onUnmounted(() => {
<div class="share"> <div class="share">
<div class="share-title">{{ t('win.shareTitle') }}</div> <div class="share-title">{{ t('win.shareTitle') }}</div>
<div class="share-buttons"> <div class="share-buttons">
<!-- X (Twitter) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')"> <button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')">
<img :src="xIcon" alt="" class="share-icon" /> <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>
</button> </button>
<!-- Facebook -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')"> <button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')">
<img :src="facebookIcon" alt="" class="share-icon" /> <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>
</button> </button>
<!-- WhatsApp -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')"> <button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')">
<img :src="whatsappIcon" alt="" class="share-icon" /> <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>
</button>
<!-- Download Screenshot (Compact) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage">
<Download :size="20" />
</button>
<!-- Download SVG (Compact) -->
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" aria-label="Download SVG" @click="downloadShareSVG">
<FileCode :size="20" />
</button> </button>
</div> </div>
<button class="btn-neon secondary share-download" :disabled="shareInProgress" @click="downloadShareImage">
{{ t('win.shareDownload') }}
</button>
</div> </div>
<div class="actions"> <div class="actions">
@@ -409,6 +613,7 @@ p {
display: block; display: block;
} }
.share-download { .share-download {
align-self: center; align-self: center;
padding: 8px 18px; padding: 8px 18px;

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ export function useNonogram() {
const dragMode = ref(null); // 'fill', 'empty', 'cross' const dragMode = ref(null); // 'fill', 'empty', 'cross'
const startCellState = ref(null); const startCellState = ref(null);
const startDrag = (r, c, isRightClick = false) => { const startDrag = (r, c, isRightClick = false, force = false) => {
if (store.isGameWon) return; if (store.isGameWon) return;
isDragging.value = true; isDragging.value = true;
@@ -16,9 +16,7 @@ export function useNonogram() {
if (isRightClick) { if (isRightClick) {
// Right click logic // Right click logic
// If current is 1 (filled), do nothing usually? Or ignore? if (!force && current === 1) {
// Standard: Toggle 0 <-> 2
if (current === 1) {
dragMode.value = null; // invalid drag start dragMode.value = null; // invalid drag start
return; return;
} }
@@ -46,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;
@@ -24,6 +24,7 @@ export function useSolver() {
return; return;
} }
if (isProcessing.value) return; if (isProcessing.value) return;
store.markGuideUsed();
ensureWorker(); ensureWorker();
isProcessing.value = true; isProcessing.value = true;
@@ -77,6 +78,9 @@ export function useSolver() {
} else if (type === 'done') { } else if (type === 'done') {
isProcessing.value = false; isProcessing.value = false;
pause(); pause();
} else if (type === 'stuck') {
isProcessing.value = false;
pause();
} else { } else {
isProcessing.value = false; isProcessing.value = false;
} }

View File

@@ -31,19 +31,3 @@ app.directive('cell-hover', vCellHover)
app.mount('#app') app.mount('#app')
if ('serviceWorker' in navigator) {
let refreshing = false
const triggerReload = () => {
if (refreshing) return
refreshing = true
window.location.reload()
}
navigator.serviceWorker.addEventListener('controllerchange', triggerReload)
const checkForUpdate = () => {
navigator.serviceWorker.getRegistration().then((registration) => registration?.update())
}
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkForUpdate()
})
window.addEventListener('focus', checkForUpdate)
}

View File

@@ -63,6 +63,10 @@ export const usePuzzleStore = defineStore('puzzle', () => {
const solution = ref([]); const solution = ref([]);
const playerGrid = ref([]); // 0: empty, 1: filled, 2: cross const playerGrid = ref([]); // 0: empty, 1: filled, 2: cross
const isGameWon = ref(false); const isGameWon = ref(false);
const hasUsedGuide = ref(false);
const guideUsageCount = ref(0);
const currentDifficulty = ref(null); // 'easy', 'medium', 'hard', 'custom' or object { density: 0.5 }
const currentDensity = ref(0);
const size = ref(5); const size = ref(5);
const startTime = ref(null); const startTime = ref(null);
const elapsedTime = ref(0); const elapsedTime = ref(0);
@@ -71,6 +75,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(() => {
@@ -116,40 +121,69 @@ export const usePuzzleStore = defineStore('puzzle', () => {
resetGrid(); resetGrid();
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false;
guideUsageCount.value = 0;
currentDensity.value = totalCellsToFill.value / (size.value * size.value);
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
saveState();
} }
function initCustomGame(customSize) { function initCustomGame(customSize, density = 0.5) {
stopTimer(); stopTimer();
currentLevelId.value = 'custom'; currentLevelId.value = 'custom';
size.value = customSize; size.value = customSize;
// Generate random grid // Generate random grid
solution.value = generateRandomGrid(customSize); solution.value = generateRandomGrid(customSize, density);
resetGrid(); resetGrid();
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false;
guideUsageCount.value = 0;
currentDensity.value = density;
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
saveState();
} }
function resetGrid() { function resetGrid() {
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); }
function endInteraction() {
if (currentTransaction.value && currentTransaction.value.length > 0) {
history.value.push(currentTransaction.value);
if (history.value.length > 50) history.value.shift(); 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();
} }
@@ -157,8 +191,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;
@@ -170,20 +202,48 @@ 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
} }
} }
@@ -238,6 +298,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
solution: solution.value, solution: solution.value,
playerGrid: playerGrid.value, playerGrid: playerGrid.value,
isGameWon: isGameWon.value, isGameWon: isGameWon.value,
hasUsedGuide: hasUsedGuide.value,
guideUsageCount: guideUsageCount.value,
currentDensity: currentDensity.value,
elapsedTime: elapsedTime.value, elapsedTime: elapsedTime.value,
moves: moves.value, moves: moves.value,
history: history.value history: history.value
@@ -255,6 +318,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
solution.value = parsed.solution; solution.value = parsed.solution;
playerGrid.value = parsed.playerGrid; playerGrid.value = parsed.playerGrid;
isGameWon.value = parsed.isGameWon; isGameWon.value = parsed.isGameWon;
hasUsedGuide.value = parsed.hasUsedGuide || false;
guideUsageCount.value = parsed.guideUsageCount || 0;
currentDensity.value = parsed.currentDensity || 0;
elapsedTime.value = parsed.elapsedTime || 0; elapsedTime.value = parsed.elapsedTime || 0;
moves.value = parsed.moves || 0; moves.value = parsed.moves || 0;
history.value = parsed.history || []; history.value = parsed.history || [];
@@ -271,44 +337,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
return false; return false;
} }
function initGame(levelId = 'easy') { // Duplicate initGame removed
// If init called without args and we have save, load it?
// User might want to start fresh if clicking buttons.
// Let's add explicit 'continue' logic or just auto-load on first run.
// For now, let's just stick to explicit init, but maybe load on mount if exists?
// The user didn't explicitly ask for "Continue", but "features from HTML".
// HTML usually auto-saves and loads.
stopTimer(); // Duplicate initCustomGame removed
currentLevelId.value = levelId;
let puzzle = PUZZLES[levelId];
if (!puzzle) {
puzzle = PUZZLES['easy'];
}
size.value = puzzle.size;
solution.value = puzzle.grid;
resetGrid();
isGameWon.value = false;
elapsedTime.value = 0;
startTimer();
saveState();
}
// Modify initCustomGame similarly
function initCustomGame(customSize) {
stopTimer();
currentLevelId.value = 'custom';
size.value = customSize;
solution.value = generateRandomGrid(customSize);
resetGrid();
isGameWon.value = false;
elapsedTime.value = 0;
startTimer();
saveState();
}
// Duplicate toggleCell/setCell removed // Duplicate toggleCell/setCell removed
@@ -316,6 +347,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
if (currentLevelId.value === 'custom') { if (currentLevelId.value === 'custom') {
resetGrid(); resetGrid();
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false;
guideUsageCount.value = 0;
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
saveState(); saveState();
@@ -324,6 +357,13 @@ export const usePuzzleStore = defineStore('puzzle', () => {
} }
} }
function markGuideUsed() {
if (isGameWon.value) return;
hasUsedGuide.value = true;
guideUsageCount.value++;
saveState();
}
function closeWinModal() { function closeWinModal() {
if (!isGameWon.value) return; if (!isGameWon.value) return;
isGameWon.value = false; isGameWon.value = false;
@@ -347,7 +387,13 @@ export const usePuzzleStore = defineStore('puzzle', () => {
loadState, // expose loadState loadState, // expose loadState
moves, moves,
undo, undo,
closeWinModal closeWinModal,
hasUsedGuide,
guideUsageCount,
currentDensity,
markGuideUsed,
startInteraction,
endInteraction
}; };
}); });

View File

@@ -1,3 +1,5 @@
@import './scrollbar.css';
:root { :root {
/* --- Glassmorphism Design System --- */ /* --- Glassmorphism Design System --- */
--bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%); --bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%);
@@ -52,6 +54,9 @@
--scroll-track: rgba(0, 0, 0, 0.1); --scroll-track: rgba(0, 0, 0, 0.1);
--scroll-thumb: rgba(255, 255, 255, 0.2); --scroll-thumb: rgba(255, 255, 255, 0.2);
--scroll-thumb-hover: var(--accent-cyan); --scroll-thumb-hover: var(--accent-cyan);
--fixed-bar-bg: rgba(0, 0, 0, 0.85);
--fixed-bar-thumb: rgba(0, 242, 255, 0.5);
--fixed-bar-thumb-hover: rgba(0, 242, 255, 0.8);
/* Rozmiary */ /* Rozmiary */
--cell-size: 30px; --cell-size: 30px;
@@ -113,6 +118,9 @@
--scroll-track: rgba(15, 23, 42, 0.08); --scroll-track: rgba(15, 23, 42, 0.08);
--scroll-thumb: rgba(15, 23, 42, 0.2); --scroll-thumb: rgba(15, 23, 42, 0.2);
--scroll-thumb-hover: #0ea5e9; --scroll-thumb-hover: #0ea5e9;
--fixed-bar-bg: rgba(248, 250, 255, 0.95);
--fixed-bar-thumb: rgba(14, 165, 233, 0.5);
--fixed-bar-thumb-hover: rgba(14, 165, 233, 0.8);
} }
* { * {
@@ -121,6 +129,11 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
html {
overflow-x: hidden;
width: 100%;
}
body { body {
margin: 0; margin: 0;
padding: 20px; padding: 20px;

54
src/styles/scrollbar.css Normal file
View File

@@ -0,0 +1,54 @@
/* Fixed Scroll Bar */
.fixed-scroll-bar {
position: fixed;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
width: 85%;
max-width: 400px;
height: 44px; /* Increased hit area */
background: transparent;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
pointer-events: none;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.fixed-scroll-track {
width: 100%;
height: 14px; /* Increased visual thickness */
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: 7px;
position: relative;
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.fixed-scroll-thumb {
position: absolute;
top: 0;
height: 100%;
background: var(--primary-accent);
border-radius: 7px;
cursor: grab;
box-shadow: 0 0 12px rgba(0, 242, 255, 0.5);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.fixed-scroll-thumb:active {
cursor: grabbing;
background: #fff;
box-shadow: 0 0 15px rgba(255, 255, 255, 0.6);
}

View File

@@ -40,15 +40,91 @@ export function calculateHints(grid) {
return { rowHints, colHints }; return { rowHints, colHints };
} }
export function generateRandomGrid(size) { export function generateRandomGrid(size, density = 0.5) {
const grid = []; const grid = [];
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
const row = []; const row = [];
for (let j = 0; j < size; j++) { for (let j = 0; j < size; j++) {
// ~50% chance of being filled row.push(Math.random() < density ? 1 : 0);
row.push(Math.random() > 0.5 ? 1 : 0);
} }
grid.push(row); grid.push(row);
} }
return grid; return grid;
} }
export function calculateDifficulty(density, size = 10) {
// Data derived from Monte Carlo Simulation (Logical Solver)
// 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
const SIM_DATA = {
5: [89, 74, 74, 81, 97, 98, 99, 100, 100],
10: [57, 20, 16, 54, 92, 100, 100, 100, 100],
15: [37, 10, 2, 12, 68, 100, 100, 100, 100],
20: [23, 3, 1, 2, 37, 100, 100, 100, 100],
25: [16, 0, 0, 1, 19, 99, 100, 100, 100],
30: [8, 0, 0, 0, 5, 99, 100, 100, 100],
35: [6, 0, 0, 0, 4, 91, 100, 100, 100],
40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
45: [2, 0, 0, 0, 1, 82, 100, 100, 100],
50: [2, 0, 0, 0, 1, 73, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
70: [0, 0, 0, 0, 0, 16, 100, 100, 100],
80: [0, 0, 0, 0, 0, 1, 100, 100, 100]
};
// Helper to get interpolated value from array
const getSimulatedSolvedPct = (s, d) => {
// Find closest sizes
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 };
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { calculateHints } from './puzzleUtils'
describe('puzzleUtils', () => {
it('calculateHints correctly calculates hints for a simple grid', () => {
const grid = [
[1, 0, 1],
[1, 1, 1],
[0, 1, 0]
]
// Row 0: 1, then space, then 1 -> [1, 1]
// Row 1: 1, 1, 1 -> [3]
// Row 2: space, 1, space -> [1]
// Col 0: 1, 1, 0 -> [2]
// Col 1: 0, 1, 1 -> [2] ? Wait. Col 1 is 0, 1, 1. So space, 1, 1 -> [2].
// Let's trace col 1 manually:
// r0,c1 = 0
// r1,c1 = 1 -> count=1
// r2,c1 = 1 -> count=2
// end -> push 2.
// So Col 1 is [2].
// Wait, my manual trace above for col 1:
// grid[0][1] is 0.
// grid[1][1] is 1.
// grid[2][1] is 1.
// Yes, [2].
// Col 2: 1, 1, 0 -> [2].
const expected = {
rowHints: [[1, 1], [3], [1]],
colHints: [[2], [2], [2]]
}
expect(calculateHints(grid)).toEqual(expected)
})
it('calculateHints handles empty rows/cols', () => {
const grid = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]
]
const expected = {
rowHints: [[0], [0], [0]],
colHints: [[0], [0], [0]]
}
expect(calculateHints(grid)).toEqual(expected)
})
})

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
};
}

View File

@@ -5,7 +5,7 @@ const messages = {
'worker.solved': 'Rozwiązane!', 'worker.solved': 'Rozwiązane!',
'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}', 'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}',
'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}', 'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}',
'worker.guess': 'Zgadywanie: Wiersz {row}, Kolumna {col}', 'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.',
'worker.done': 'Koniec!', 'worker.done': 'Koniec!',
'worker.state.filled': 'Pełne', 'worker.state.filled': 'Pełne',
'worker.state.empty': 'Puste' 'worker.state.empty': 'Puste'
@@ -14,7 +14,7 @@ const messages = {
'worker.solved': 'Solved!', 'worker.solved': 'Solved!',
'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}', 'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}',
'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}', 'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}',
'worker.guess': 'Guessing: Row {row}, Column {col}', 'worker.stuck': 'No logical move found. Try guessing or undoing.',
'worker.done': 'Done!', 'worker.done': 'Done!',
'worker.state.filled': 'Filled', 'worker.state.filled': 'Filled',
'worker.state.empty': 'Empty' 'worker.state.empty': 'Empty'
@@ -91,16 +91,34 @@ const solveLineLogic = (lineState, hints) => {
return result; return result;
} }
const len = hints[hintIndex]; const len = hints[hintIndex];
// maxStart logic: we need enough space for this block (len) + subsequent blocks/gaps (suffixMin[hintIndex+1])
// suffixMin[hintIndex] = len + (m - hintIndex - 1) + suffixMin[hintIndex+1]
// Actually suffixMin[hintIndex] already includes everything needed from here to end.
// So if we place block at start, end is start + len.
// Total space needed is suffixMin[hintIndex].
// So start can go up to n - suffixMin[hintIndex].
const maxStart = n - suffixMin[hintIndex]; const maxStart = n - suffixMin[hintIndex];
for (let start = pos; start <= maxStart; start++) { for (let start = pos; start <= maxStart; start++) {
if (hasFilled(pos, start)) continue; if (hasFilled(pos, start)) continue; // Must be empty before this block
if (hasCross(start, start + len)) continue; if (hasCross(start, start + len)) continue; // Block space must be free of crosses
if (start + len < n && lineState[start + len] === 1) continue;
const nextPos = start + len < n ? start + len + 1 : start + len; // If not the last block, we need a gap after
if (hintIndex < m - 1) {
if (start + len < n && lineState[start + len] === 1) continue; // Gap must not be filled
// We can assume gap is at start + len. Next block starts at least at start + len + 1
const nextPos = start + len + 1;
if (canPlaceSuffix(nextPos, hintIndex + 1)) { if (canPlaceSuffix(nextPos, hintIndex + 1)) {
memoSuffix[pos][hintIndex] = true; memoSuffix[pos][hintIndex] = true;
return true; return true;
} }
} else {
// Last block
// Check if we can fill the rest with empty
if (hasFilled(start + len, n)) continue;
memoSuffix[pos][hintIndex] = true;
return true;
}
} }
memoSuffix[pos][hintIndex] = false; memoSuffix[pos][hintIndex] = false;
return false; return false;
@@ -115,17 +133,43 @@ const solveLineLogic = (lineState, hints) => {
return result; return result;
} }
const len = hints[hintCount - 1]; const len = hints[hintCount - 1];
const maxStart = pos - len;
// Logic for prefix:
// We are placing the (hintCount-1)-th block ending at 'start + len' <= pos.
// So 'start' <= pos - len.
// But we also need to ensure there is space for previous blocks.
// However, the simple constraint is just iterating backwards.
// maxStart: if this is the only block, maxStart = pos - len.
// If there are previous blocks, we need a gap before this block.
// So previous block ended at start - 1.
// Actually the recursive call will handle space check.
// But for the gap check:
// If we place block at 'start', we need lineState[start-1] != 1 (if start > 0).
// And we recursively check canPlacePrefix(start-1, count-1).
// But if start=0 and count > 1, impossible.
const maxStart = pos - len; // Simplified, loop condition handles rest
for (let start = maxStart; start >= 0; start--) { for (let start = maxStart; start >= 0; start--) {
if (hasCross(start, start + len)) continue; if (hasCross(start, start + len)) continue;
if (start + len < pos && lineState[start + len] === 1) continue; if (hasFilled(start + len, pos)) continue; // Must be empty after this block up to pos
if (hasFilled(start + len, pos)) continue;
if (start > 0 && lineState[start - 1] === 1) continue; // Check gap before
const prevPos = start > 0 ? start - 1 : 0; if (hintCount > 1) {
if (start === 0) continue; // No space for previous blocks
if (lineState[start - 1] === 1) continue; // Gap must not be filled
const prevPos = start - 1;
if (canPlacePrefix(prevPos, hintCount - 1)) { if (canPlacePrefix(prevPos, hintCount - 1)) {
memoPrefix[pos][hintCount] = true; memoPrefix[pos][hintCount] = true;
return true; return true;
} }
} else {
// First block
if (hasFilled(0, start)) continue; // Before first block must be empty
memoPrefix[pos][hintCount] = true;
return true;
}
} }
memoPrefix[pos][hintCount] = false; memoPrefix[pos][hintCount] = false;
return false; return false;
@@ -136,7 +180,14 @@ const solveLineLogic = (lineState, hints) => {
const len = hints[i]; const len = hints[i];
const starts = []; const starts = [];
for (let start = 0; start <= n - len; start++) { for (let start = 0; start <= n - len; start++) {
if (!canPlacePrefix(start, i)) continue; if (i === 0) {
if (!canPlacePrefix(start, 0)) continue;
} else {
if (start === 0) continue;
if (lineState[start - 1] === 1) continue;
if (!canPlacePrefix(start - 1, i)) continue;
}
if (hasCross(start, start + len)) continue; if (hasCross(start, start + len)) continue;
if (start + len < n && lineState[start + len] === 1) continue; if (start + len < n && lineState[start + len] === 1) continue;
const nextPos = start + len < n ? start + len + 1 : start + len; const nextPos = start + len < n ? start + len + 1 : start + len;
@@ -236,29 +287,9 @@ const handleStep = (playerGrid, solution, locale) => {
} }
} }
for (let r = 0; r < size; r++) { // Check for guess logic - we want to avoid this unless strictly necessary
for (let c = 0; c < size; c++) { // If no logic move found, return 'stuck' instead of cheating
const current = playerGrid[r][c]; return { type: 'stuck', statusText: t(locale, 'worker.stuck') };
const target = solution[r][c];
let isCorrect = false;
if (target === 1 && current === 1) isCorrect = true;
if (target === 0 && current === 2) isCorrect = true;
if (target === 0 && current === 0) isCorrect = false;
if (target === 1 && current === 0) isCorrect = false;
if (!isCorrect) {
const newState = target === 1 ? 1 : 2;
return {
type: 'move',
r,
c,
state: newState,
statusText: t(locale, 'worker.guess', { row: r + 1, col: c + 1 })
};
}
}
}
return { type: 'done', statusText: t(locale, 'worker.done') };
}; };
self.onmessage = (event) => { self.onmessage = (event) => {

60
update_i18n_guide.cjs Normal file
View File

@@ -0,0 +1,60 @@
const fs = require('fs');
const filePath = 'src/composables/useI18n.js';
let content = fs.readFileSync(filePath, 'utf8');
// 1. Add key to all language objects
const lines = content.split('\n');
const newLines = [];
let insideLang = false;
let currentLang = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const langStartMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
if (langStartMatch) {
insideLang = true;
currentLang = langStartMatch[1].replace(/['"]/g, '');
}
if (insideLang && (line.trim() === '},' || line.trim() === '}')) {
let translation = 'GUIDE';
if (currentLang === 'pl') translation = 'PRZEWODNIK';
if (currentLang === 'es') translation = 'GUÍA';
if (currentLang === 'fr') translation = 'GUIDE';
if (currentLang === 'de') translation = 'ANLEITUNG';
if (currentLang === 'it') translation = 'GUIDA';
if (currentLang === 'pt' || currentLang === 'pt-br') translation = 'GUIA';
if (currentLang === 'ru') translation = 'РУКОВОДСТВО';
if (currentLang === 'zh') translation = '指南';
// Ensure previous line has comma
if (newLines.length > 0) {
const lastLine = newLines[newLines.length - 1];
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
newLines[newLines.length - 1] = lastLine + ',';
}
}
newLines.push(` 'nav.guide': '${translation}'`);
insideLang = false;
currentLang = null;
}
newLines.push(line);
}
content = newLines.join('\n');
// 2. Add to requiredKeys
// Find "const requiredKeys = ["
// We know it ends with 'nav.newGame' now.
content = content.replace(
"'nav.newGame'",
"'nav.newGame','nav.guide'"
);
fs.writeFileSync(filePath, content);
console.log('Updated useI18n.js with nav.guide');

View File

@@ -4,11 +4,18 @@ import { VitePWA } from 'vite-plugin-pwa'
import path from 'path' import path from 'path'
export default defineConfig({ export default defineConfig({
define: {
'__APP_VERSION__': JSON.stringify(process.env.npm_package_version)
},
plugins: [ plugins: [
vue(), vue(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'prompt',
injectRegister: 'auto', injectRegister: 'auto',
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}']
},
devOptions: { devOptions: {
enabled: true enabled: true
}, },

16
vitest.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})