59 Commits

Author SHA1 Message Date
43c0290fac 1.14.1
All checks were successful
Deploy to Production / deploy (push) Successful in 18s
2026-02-13 06:14:56 +01:00
fa5fa12157 fix: resetGrid visibility in puzzle store and hide camera switch on single-cam devices 2026-02-13 06:14:51 +01:00
29682c9a06 1.14.0
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-13 05:54:29 +01:00
2d30315ae6 fix(solver): unify worker logic with main solver and fix skipping filled cells bug 2026-02-13 05:54:23 +01:00
2261f44b4a Bump version to 1.13.1
All checks were successful
Deploy to Production / deploy (push) Successful in 18s
2026-02-13 05:27:43 +01:00
2cd32d4a3e Optimize simulation with logic-only solver, fix rectangular grid support, and improve worker pool
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-02-13 05:18:55 +01:00
48def6c400 feat: enhance image import and solvability calculation (v1.13.0)
All checks were successful
Deploy to Production / deploy (push) Successful in 18s
- Implement non-linear threshold slider (histogram percentile method)
- Add real-time solvability calculation with progress indicator
- Improve solvability logic with generative lookahead (smash)
- Update ImageImportModal UI (alpha preview, grid size 5-80)
- Add missing translations and difficulty labels
- Optimize web worker pool with queue clearing and progress reporting
- Fix mobile camera support and UI layout
2026-02-13 02:23:44 +01:00
f1f3f81466 feat: update difficulty calculation with high-res simulation data v1.12.11
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-12 23:10:15 +01:00
121e4c418f 1.12.10
All checks were successful
Deploy to Production / deploy (push) Successful in 17s
2026-02-12 22:55:00 +01:00
5583a08b9a fix: adjust difficulty formula for small grids 2026-02-12 22:54:50 +01:00
fed32c6cbe 1.12.9
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-12 22:47:30 +01:00
99052a2b6c feat: enhance mobile PWA install banner 2026-02-12 22:38:03 +01:00
f483d39a2c chore(release): bump version for deployment check
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-12 22:24:18 +01:00
29952b22e7 fix(ci): remove volume mount for nginx.conf
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-02-12 22:20:55 +01:00
c42210ac24 fix(ci): force remove old container before deploy
Some checks failed
Deploy to Production / deploy (push) Failing after 7s
2026-02-12 22:14:55 +01:00
0799cb2162 fix(ci): force remove old container before deploy
Some checks failed
Deploy to Production / deploy (push) Failing after 7s
2026-02-12 22:12:51 +01:00
2cd2291d03 chore: trigger ci retry
Some checks failed
Deploy to Production / deploy (push) Failing after 23s
2026-02-12 22:08:08 +01:00
ec20d5ee8f chore: release 1.12.7
Some checks failed
Deploy to Production / deploy (push) Failing after 1s
2026-02-12 21:57:00 +01:00
b20a829d37 chore: release 1.12.6
Some checks failed
Deploy to Production / deploy (push) Failing after 9s
2026-02-12 21:15:19 +01:00
988c4a899b ci: add gitea actions workflow
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-02-12 21:10:46 +01:00
d8faa308e6 chore: release 1.12.5 2026-02-12 20:50:18 +01:00
6bddb24bfe chore: remove redundant check_i18n.js script 2026-02-12 20:50:16 +01:00
1c2be3567a chore: bump version to 1.12.4 2026-02-12 20:41:22 +01:00
Grzegorz Kućmierz
d62cec415b Update README with screenshot and play link
Added screenshot to README and updated play instructions.
2026-02-12 20:34:22 +01:00
8be28a4472 docs: update application screenshot 2026-02-12 20:22:24 +01:00
48778b3e8a chore: ignore and remove .DS_Store files 2026-02-12 20:18:48 +01:00
8bd5d5c3e6 chore: bump version to 1.12.3 2026-02-12 20:13:10 +01:00
cf37ccd843 chore: remove dev dependencies (puppeteer, sharp) 2026-02-12 20:11:06 +01:00
9a65dfe55d docs: add application screenshot to README 2026-02-12 20:03:31 +01:00
2a88362d00 fix: add PNG icons for PWA compatibility (Android/iOS) 2026-02-12 19:49:15 +01:00
8e3ae3e7d6 fix: add PNG icons for PWA compatibility (Android/iOS) 2026-02-12 19:47:00 +01:00
51bbe0cb52 fix: improve iOS detection for PWA prompt 2026-02-12 19:39:05 +01:00
08292039cf 1.12.0 2026-02-12 19:29:54 +01:00
934b2a0483 feat: refine difficulty calculation and update simulation data 2026-02-12 18:51:00 +01:00
27270d6452 fix: landscape modals and iOS PWA support 2026-02-12 18:32:49 +01:00
a4681b5b97 fix: improve mobile scrollbar visibility and resize handling 2026-02-12 18:17:06 +01:00
8327597e2e fix: hide mobile menu scrollbar & bump version to 1.11.1 2026-02-12 17:19:18 +01:00
bae67fc1ec chore: bump version to 1.11.0 2026-02-12 17:05:06 +01:00
47426d529a ui: unify app icons with new nonogram-style N logo 2026-02-12 17:03:47 +01:00
71edc3103d chore: bump version to 1.10.0 2026-02-12 16:56:53 +01:00
30c1faeae4 fix: make app link clickable in generated SVG 2026-02-12 16:52:36 +01:00
b90809fca1 style: dodaj margines po prawej stronie planszy w wersji desktop 2026-02-12 16:43:21 +01:00
322182245c docs: add .gpg to gitignore 2026-02-12 15:54:13 +01:00
324b761d37 1.9.14 2026-02-12 15:08:45 +01:00
4dab0e2c63 chore: homepage w package.json; __APP_HOMEPAGE__ w Vite; użycie w shareUtils 2026-02-12 15:00:00 +01:00
b3e08b53fc Update README.md 2026-02-12 13:33:02 +00:00
3ce15ed794 1.9.13 2026-02-12 14:28:11 +01:00
bd310d8305 ui: linie pomocnicze co 5 w opisach wierszy i kolumn 2026-02-12 14:27:51 +01:00
a22897e19e 1.9.12 2026-02-12 14:21:05 +01:00
6ec3e66e9c i18n: uzupełnij etykiety language.*; skrypty tłumaczeń 2026-02-12 14:21:05 +01:00
defde986a4 docs: usuń sekcję linku z README 2026-02-12 14:19:44 +01:00
bb3752b8ae 1.9.11 2026-02-12 14:08:40 +01:00
be04f333b0 feat: tłumaczenia symulacji w wielu językach; prędkości x32/x64; README EN; MIT license 2026-02-12 14:08:30 +01:00
fc25246594 1.9.10 2026-02-12 13:58:44 +01:00
d7e104c17a fix: priorytet ESC i zatrzymanie propagacji w modalach 2026-02-12 13:58:28 +01:00
c197445f35 1.9.9 2026-02-12 13:51:28 +01:00
57ae54d716 feat: ESC zamyka wszystkie modale (Custom Game, Simulation, Win) 2026-02-12 13:51:10 +01:00
82a3717689 1.9.8 2026-02-12 13:46:39 +01:00
d4c93af2c2 chore: dodanie nonograms.svg i podmiana favicon 2026-02-12 13:46:30 +01:00
47 changed files with 15619 additions and 1504 deletions

View File

@@ -0,0 +1,23 @@
name: Deploy to Production
run-name: Deploy to Production by @${{ github.actor }}
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build and deploy with Docker Compose
run: |
# Próba zatrzymania i usunięcia starego kontenera (ignoruje błąd jeśli nie istnieje)
docker compose down --remove-orphans || true
docker rm -f nonograms-app || true
# Start nowej wersji
docker compose up -d --build

6
.gitignore vendored
View File

@@ -1,7 +1,3 @@
.gpg/
node_modules node_modules
dist
.DS_Store .DS_Store
.vscode
.idea
*.log
dev-dist

23
LICENSE Normal file
View File

@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 gkucmierz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
See README.md for project description.

View File

@@ -1,3 +1,13 @@
# Nonograms # Nonograms
Link do aplikacji: https://nonograms.7u.pl ## Description
Nonograms is a modern, fast, and accessible logic puzzle game (also known as Picross or Griddlers). Solve pixel-art puzzles by marking cells according to numeric clues for rows and columns. The app features:
- Clean UX with keyboard and touch support
- Multiple languages and PWA support (installable on desktop and mobile)
- Difficulty simulation and guide to learn solving strategies
- Shareable puzzles and persistent progress
![Nonograms Application Screenshot](public/screenshot.png)
Play online at https://nonograms.7u.pl or install as a PWA for an app-like experience.

View File

@@ -1,20 +1,13 @@
const fs = require('fs'); const fs = require('fs');
const fileContent = fs.readFileSync('src/composables/useI18n.js', 'utf8'); const fileContent = fs.readFileSync('src/composables/useI18n.js', 'utf8');
// Extract the messages object
const match = fileContent.match(/const messages = ({[\s\S]*?});/); const match = fileContent.match(/const messages = ({[\s\S]*?});/);
if (!match) { if (!match) {
console.error('Could not find messages object'); console.error('Could not find messages object');
process.exit(1); 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 messagesStr = match[1];
const messages = eval(`(${messagesStr})`); const messages = eval(`(${messagesStr})`);
@@ -25,10 +18,8 @@ const missing = {};
languages.forEach(lang => { languages.forEach(lang => {
if (lang === 'en') return; if (lang === 'en') return;
const langKeys = Object.keys(messages[lang]); const langKeys = Object.keys(messages[lang]);
const missingKeys = enKeys.filter(k => !langKeys.includes(k)); const missingKeys = enKeys.filter(k => !langKeys.includes(k));
if (missingKeys.length > 0) { if (missingKeys.length > 0) {
missing[lang] = missingKeys; missing[lang] = missingKeys;
} }

92
dev-dist/sw.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* 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-7a5e81cd'], (function (workbox) { 'use strict';
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "index.html",
"revision": "0.kkc80cp3p5o"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

3377
dev-dist/workbox-7a5e81cd.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,938 +0,0 @@
[
{
"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
}
]

View File

@@ -10,5 +10,5 @@ services:
- "8081:80" - "8081:80"
restart: unless-stopped restart: unless-stopped
# Uncomment the following lines if you want to mount the configuration locally for development/testing # Uncomment the following lines if you want to mount the configuration locally for development/testing
volumes: # volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro # - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

View File

@@ -2,9 +2,9 @@
<html lang="pl"> <html lang="pl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/pwa-192x192.svg" /> <link rel="icon" type="image/svg+xml" href="/nonograms.svg" />
<link rel="apple-touch-icon" href="/pwa-192x192.svg" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/pwa-192x192.svg" color="#00f2fe" /> <link rel="mask-icon" href="/nonograms.svg" color="#00f2fe" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nonograms Pro - Vue 3 SOLID</title> <title>Nonograms Pro - Vue 3 SOLID</title>
</head> </head>

4
package-lock.json generated
View File

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

View File

@@ -1,9 +1,10 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.9.7", "version": "1.14.1",
"homepage": "https://nonograms.7u.pl/",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest" "test": "vitest"

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

44
public/nonograms.svg Normal file
View File

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#43C6AC"/>
<stop offset="1" stop-color="#191654"/>
</linearGradient>
<linearGradient id="cell" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00f2fe"/>
<stop offset="1" stop-color="#4facfe"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<!-- Main Background -->
<rect width="192" height="192" rx="42" fill="url(#bg)"/>
<!-- Console Screen Background -->
<rect x="26" y="26" width="140" height="140" rx="16" fill="rgba(0,10,30,0.5)" stroke="rgba(0,242,254,0.2)" stroke-width="1.5"/>
<!-- Letter N built from Nonogram cells -->
<g fill="url(#cell)" filter="url(#glow)">
<!-- Left Column -->
<rect x="38" y="38" width="20" height="20" rx="4"/>
<rect x="38" y="62" width="20" height="20" rx="4"/>
<rect x="38" y="86" width="20" height="20" rx="4"/>
<rect x="38" y="110" width="20" height="20" rx="4"/>
<rect x="38" y="134" width="20" height="20" rx="4"/>
<!-- Diagonal -->
<rect x="62" y="62" width="20" height="20" rx="4"/>
<rect x="86" y="86" width="20" height="20" rx="4"/>
<rect x="110" y="110" width="20" height="20" rx="4"/>
<!-- Right Column -->
<rect x="134" y="38" width="20" height="20" rx="4"/>
<rect x="134" y="62" width="20" height="20" rx="4"/>
<rect x="134" y="86" width="20" height="20" rx="4"/>
<rect x="134" y="110" width="20" height="20" rx="4"/>
<rect x="134" y="134" width="20" height="20" rx="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -8,17 +8,37 @@
<stop offset="0" stop-color="#00f2fe"/> <stop offset="0" stop-color="#00f2fe"/>
<stop offset="1" stop-color="#4facfe"/> <stop offset="1" stop-color="#4facfe"/>
</linearGradient> </linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs> </defs>
<rect width="192" height="192" rx="28" fill="url(#bg)"/>
<rect x="28" y="28" width="136" height="136" rx="14" fill="rgba(0,0,0,0.35)"/> <!-- Main Background -->
<g fill="url(#cell)"> <rect width="192" height="192" rx="42" fill="url(#bg)"/>
<rect x="48" y="48" width="20" height="20" rx="4"/>
<rect x="76" y="48" width="20" height="20" rx="4"/> <!-- Console Screen Background -->
<rect x="104" y="48" width="20" height="20" rx="4"/> <rect x="26" y="26" width="140" height="140" rx="16" fill="rgba(0,10,30,0.5)" stroke="rgba(0,242,254,0.2)" stroke-width="1.5"/>
<rect x="48" y="76" width="20" height="20" rx="4"/>
<rect x="104" y="76" width="20" height="20" rx="4"/> <!-- Letter N built from Nonogram cells -->
<rect x="48" y="104" width="20" height="20" rx="4"/> <g fill="url(#cell)" filter="url(#glow)">
<rect x="76" y="104" width="20" height="20" rx="4"/> <!-- Left Column -->
<rect x="104" y="104" width="20" height="20" rx="4"/> <rect x="38" y="38" width="20" height="20" rx="4"/>
<rect x="38" y="62" width="20" height="20" rx="4"/>
<rect x="38" y="86" width="20" height="20" rx="4"/>
<rect x="38" y="110" width="20" height="20" rx="4"/>
<rect x="38" y="134" width="20" height="20" rx="4"/>
<!-- Diagonal -->
<rect x="62" y="62" width="20" height="20" rx="4"/>
<rect x="86" y="86" width="20" height="20" rx="4"/>
<rect x="110" y="110" width="20" height="20" rx="4"/>
<!-- Right Column -->
<rect x="134" y="38" width="20" height="20" rx="4"/>
<rect x="134" y="62" width="20" height="20" rx="4"/>
<rect x="134" y="86" width="20" height="20" rx="4"/>
<rect x="134" y="110" width="20" height="20" rx="4"/>
<rect x="134" y="134" width="20" height="20" rx="4"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 192 192">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#43C6AC"/> <stop offset="0" stop-color="#43C6AC"/>
@@ -8,17 +8,37 @@
<stop offset="0" stop-color="#00f2fe"/> <stop offset="0" stop-color="#00f2fe"/>
<stop offset="1" stop-color="#4facfe"/> <stop offset="1" stop-color="#4facfe"/>
</linearGradient> </linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs> </defs>
<rect width="512" height="512" rx="80" fill="url(#bg)"/>
<rect x="74" y="74" width="364" height="364" rx="40" fill="rgba(0,0,0,0.35)"/> <!-- Main Background -->
<g fill="url(#cell)"> <rect width="192" height="192" rx="42" fill="url(#bg)"/>
<rect x="138" y="138" width="54" height="54" rx="10"/>
<rect x="214" y="138" width="54" height="54" rx="10"/> <!-- Console Screen Background -->
<rect x="290" y="138" width="54" height="54" rx="10"/> <rect x="26" y="26" width="140" height="140" rx="16" fill="rgba(0,10,30,0.5)" stroke="rgba(0,242,254,0.2)" stroke-width="1.5"/>
<rect x="138" y="214" width="54" height="54" rx="10"/>
<rect x="290" y="214" width="54" height="54" rx="10"/> <!-- Letter N built from Nonogram cells -->
<rect x="138" y="290" width="54" height="54" rx="10"/> <g fill="url(#cell)" filter="url(#glow)">
<rect x="214" y="290" width="54" height="54" rx="10"/> <!-- Left Column -->
<rect x="290" y="290" width="54" height="54" rx="10"/> <rect x="38" y="38" width="20" height="20" rx="4"/>
<rect x="38" y="62" width="20" height="20" rx="4"/>
<rect x="38" y="86" width="20" height="20" rx="4"/>
<rect x="38" y="110" width="20" height="20" rx="4"/>
<rect x="38" y="134" width="20" height="20" rx="4"/>
<!-- Diagonal -->
<rect x="62" y="62" width="20" height="20" rx="4"/>
<rect x="86" y="86" width="20" height="20" rx="4"/>
<rect x="110" y="110" width="20" height="20" rx="4"/>
<!-- Right Column -->
<rect x="134" y="38" width="20" height="20" rx="4"/>
<rect x="134" y="62" width="20" height="20" rx="4"/>
<rect x="134" y="86" width="20" height="20" rx="4"/>
<rect x="134" y="110" width="20" height="20" rx="4"/>
<rect x="134" y="134" width="20" height="20" rx="4"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,49 @@
const fs = require('fs');
const path = 'src/composables/useI18n.js';
let content = fs.readFileSync(path, 'utf8');
const messagesMatch = content.match(/const messages = ({[\s\S]*?});/);
if (!messagesMatch) {
console.error('Could not find messages object');
process.exit(1);
}
const messages = eval(`(${messagesMatch[1]})`);
const en = messages.en || {};
const allLangKeys = Object.keys(en).filter((k) => k.startsWith('language.'));
function injectMissingLanguageLabels(localeCode) {
const blockStartRegex = new RegExp(`\\s{2}['\"]?${localeCode}['\"]?\\s*:\\s*\\{`);
const startIndex = content.search(blockStartRegex);
if (startIndex === -1) return;
const braceStart = content.indexOf('{', startIndex);
let i = braceStart + 1;
let depth = 1;
while (i < content.length && depth > 0) {
if (content[i] === '{') depth++;
else if (content[i] === '}') depth--;
i++;
}
const block = content.slice(braceStart + 1, i - 1);
let updated = block;
let addedAny = false;
allLangKeys.forEach((key) => {
const keyRegex = new RegExp(`(['\"])${key}\\1\\s*:\\s*(['\"]).*?\\2`);
if (!keyRegex.test(updated)) {
const value = en[key];
updated = updated.trim().endsWith(',') ? updated + `\n '${key}': '${value}'` : updated + `,\n '${key}': '${value}'`;
addedAny = true;
}
});
if (addedAny) {
content = content.slice(0, braceStart + 1) + updated + content.slice(i - 1);
}
}
Object.keys(messages).forEach((locale) => {
if (locale === 'en') return;
injectMissingLanguageLabels(locale);
});
fs.writeFileSync(path, content);
console.log('Filled missing language.* labels for all locales.');

42
scripts/generate-icons.js Normal file
View File

@@ -0,0 +1,42 @@
import sharp from 'sharp';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const INPUT_FILE = path.join(__dirname, '../public/nonograms.svg');
const OUTPUT_DIR = path.join(__dirname, '../public');
async function generateIcons() {
console.log('Generating icons from ' + INPUT_FILE);
try {
// 192x192
await sharp(INPUT_FILE)
.resize(192, 192)
.png()
.toFile(path.join(OUTPUT_DIR, 'pwa-192x192.png'));
console.log('Created pwa-192x192.png');
// 512x512
await sharp(INPUT_FILE)
.resize(512, 512)
.png()
.toFile(path.join(OUTPUT_DIR, 'pwa-512x512.png'));
console.log('Created pwa-512x512.png');
// Apple Touch Icon (180x180)
await sharp(INPUT_FILE)
.resize(180, 180)
.png()
.toFile(path.join(OUTPUT_DIR, 'apple-touch-icon.png'));
console.log('Created apple-touch-icon.png');
} catch (err) {
console.error('Error generating icons:', err);
process.exit(1);
}
}
generateIcons();

View File

@@ -0,0 +1,47 @@
import { generateRandomGrid, calculateHints } from '../src/utils/puzzleUtils.js';
import { solvePuzzle } from '../src/utils/solver.js';
import fs from 'fs';
const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80];
const DENSITIES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
const SAMPLES = 200; // High sample count for stability
console.log('Starting simulation...');
console.log(`Sizes: ${SIZES.join(', ')}`);
console.log(`Densities: ${DENSITIES.join(', ')}`);
console.log(`Samples per point: ${SAMPLES}`);
const results = {};
async function run() {
const totalSteps = SIZES.length * DENSITIES.length;
let currentStep = 0;
const startTime = Date.now();
for (const size of SIZES) {
results[size] = [];
for (const density of DENSITIES) {
let totalSolved = 0;
for (let i = 0; i < SAMPLES; i++) {
const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid);
const { percentSolved } = solvePuzzle(rowHints, colHints);
totalSolved += percentSolved;
}
const avg = Math.round(totalSolved / SAMPLES);
results[size].push(avg);
currentStep++;
const pct = Math.round((currentStep / totalSteps) * 100);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
process.stdout.write(`\rProgress: ${pct}% (${currentStep}/${totalSteps}) - Elapsed: ${elapsed}s`);
}
}
console.log('\n\nSimulation complete!');
console.log('SIM_DATA = ' + JSON.stringify(results, null, 4) + ';');
fs.writeFileSync('simulation_results.json', JSON.stringify(results, null, 4));
}
run();

View File

@@ -1,75 +1,38 @@
import fs from 'fs';
import path from 'path';
import { generateRandomGrid, calculateHints } from '../src/utils/puzzleUtils.js'; import { generateRandomGrid, calculateHints } from '../src/utils/puzzleUtils.js';
import { solvePuzzle } from '../src/utils/solver.js'; import { solvePuzzle } from '../src/utils/solver.js';
const OUTPUT_FILE = 'difficulty_simulation_results.json'; const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80];
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 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 const SAMPLES_SMALL = 100; // For size <= 25
const SAMPLES_LARGE = 30; // For size > 25
console.log('Starting Monte Carlo Simulation for Nonogram Difficulty...'); const results = {};
console.log(`Config: Sizes=${SIZES.length}, Densities=${DENSITIES.length}, Samples=${SAMPLES_PER_POINT}`);
const results = []; console.log('Starting Monte Carlo Simulation...');
const csvRows = ['size,density,avg_solved_percent,min_solved_percent,max_solved_percent,avg_time_ms'];
const startTime = Date.now(); const startTime = Date.now();
for (const size of SIZES) { for (const size of SIZES) {
const samples = size <= 25 ? SAMPLES_SMALL : SAMPLES_LARGE;
const rowData = [];
for (const density of DENSITIES) { for (const density of DENSITIES) {
let totalSolved = 0; 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; i++) {
for (let i = 0; i < SAMPLES_PER_POINT; i++) {
const t0 = performance.now();
// 1. Generate
const grid = generateRandomGrid(size, density); const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid); const { rowHints, colHints } = calculateHints(grid);
// 2. Solve
const { percentSolved } = solvePuzzle(rowHints, colHints); const { percentSolved } = solvePuzzle(rowHints, colHints);
const t1 = performance.now();
totalSolved += percentSolved; totalSolved += percentSolved;
minSolved = Math.min(minSolved, percentSolved);
maxSolved = Math.max(maxSolved, percentSolved);
totalTime += (t1 - t0);
} }
const avgSolved = totalSolved / SAMPLES_PER_POINT; const avg = Math.round(totalSolved / samples);
const avgTime = totalTime / SAMPLES_PER_POINT; rowData.push(avg);
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)}%`);
} }
results[size] = rowData;
console.log(` Size ${size}: [${rowData.join(', ')}]`);
} }
const totalDuration = (Date.now() - startTime) / 1000; const duration = (Date.now() - startTime) / 1000;
console.log(`Simulation complete in ${totalDuration.toFixed(1)}s`); console.log(`\nSimulation Complete in ${duration.toFixed(2)}s. Result JSON:`);
console.log(JSON.stringify(results, null, 4));
// 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

@@ -0,0 +1,514 @@
const fs = require('fs');
const path = 'src/composables/useI18n.js';
let content = fs.readFileSync(path, 'utf8');
const keys = {
'custom.showMap': {
es: 'Mostrar mapa de dificultad',
fr: 'Afficher la carte de difficulté',
de: 'Schwierigkeitskarte anzeigen',
it: 'Mostra mappa difficoltà',
pt: 'Mostrar mapa de dificuldade',
'pt-br': 'Mostrar mapa de dificuldade',
ru: 'Показать карту сложности',
zh: '显示难度地图',
ja: '難易度マップを表示',
ko: '난이도 맵 표시',
tr: 'Zorluk haritasını göster',
uk: 'Показати карту складності',
cs: 'Zobrazit mapu obtížnosti',
sk: 'Zobraziť mapu náročnosti',
hu: 'Nehézségi térkép megjelenítése',
ro: 'Arată harta dificultății',
bg: 'Покажи картата на трудността',
el: 'Εμφάνιση χάρτη δυσκολίας',
sr: 'Прикажи карту тежине',
hr: 'Prikaži kartu težine',
sl: 'Prikaži zemljevid težavnosti',
lt: 'Rodyti sudėtingumo žemėlapį',
lv: 'Rādīt grūtības karti',
et: 'Näita raskusaste kaarti',
nl: 'Moeilijkheidskaart weergeven',
sv: 'Visa svårighetskarta',
da: 'Vis sværhedsgradskort',
fi: 'Näytä vaikeuskartta',
no: 'Vis vanskelighetskart',
ar: 'إظهار خريطة الصعوبة',
hi: 'कठिनाई मानचित्र दिखाएँ',
bn: 'কঠিনতার মানচিত্র দেখান'
},
'custom.hideMap': {
es: 'Ocultar mapa de dificultad',
fr: 'Masquer la carte de difficulté',
de: 'Schwierigkeitskarte ausblenden',
it: 'Nascondi mappa difficoltà',
pt: 'Ocultar mapa de dificuldade',
'pt-br': 'Ocultar mapa de dificuldade',
ru: 'Скрыть карту сложности',
zh: '隐藏难度地图',
ja: '難易度マップを非表示',
ko: '난이도 맵 숨기기',
tr: 'Zorluk haritasını gizle',
uk: 'Приховати карту складності',
cs: 'Skrýt mapu obtížnosti',
sk: 'Skryť mapu náročnosti',
hu: 'Nehézségi térkép elrejtése',
ro: 'Ascunde harta dificultății',
bg: 'Скрий картата на трудността',
el: 'Απόκρυψη χάρτη δυσκολίας',
sr: 'Сакриј карту тежине',
hr: 'Sakrij kartu težine',
sl: 'Skrij zemljevid težavnosti',
lt: 'Slėpti sudėtingumo žemėlapį',
lv: 'Paslēpt grūtības karti',
et: 'Peida raskusaste kaart',
nl: 'Moeilijkheidskaart verbergen',
sv: 'Dölj svårighetskarta',
da: 'Skjul sværhedsgradskort',
fi: 'Piilota vaikeuskartta',
no: 'Skjul vanskelighetskart',
ar: 'إخفاء خريطة الصعوبة',
hi: 'कठिनाई मानचित्र छुपाएँ',
bn: 'কঠিনতার মানচিত্র লুকান'
},
'simulation.title': {
es: 'Simulación de Dificultad',
fr: 'Simulation de difficulté',
de: 'Schwierigkeitssimulation',
it: 'Simulazione della difficoltà',
pt: 'Simulação de Dificuldade',
'pt-br': 'Simulação de Dificuldade',
ru: 'Симуляция сложности',
zh: '难度模拟',
ja: '難易度シミュレーション',
ko: '난이도 시뮬레이션',
tr: 'Zorluk simülasyonu',
uk: 'Симуляція складності',
cs: 'Simulace obtížnosti',
sk: 'Simulácia náročnosti',
hu: 'Nehézség szimuláció',
ro: 'Simulare de dificultate',
bg: 'Симулиране на трудност',
el: 'Προσομοίωση δυσκολίας',
sr: 'Симулација тежине',
hr: 'Simulacija težine',
sl: 'Simulacija težavnosti',
lt: 'Sudėtingumo simuliacija',
lv: 'Grūtības simulācija',
et: 'Raskusastme simulatsioon',
nl: 'Moeilijkheidssimulatie',
sv: 'Svårighetssimulering',
da: 'Sværhedsgradssimulering',
fi: 'Vaikeussimulointi',
no: 'Vanskelighetssimulering',
ar: 'محاكاة الصعوبة',
hi: 'कठिनाई सिमुलेशन',
bn: 'কঠিনতা সিমুলেশন'
},
'simulation.status.ready': {
es: 'Listo',
fr: 'Prêt',
de: 'Bereit',
it: 'Pronto',
pt: 'Pronto',
'pt-br': 'Pronto',
ru: 'Готово',
zh: '就绪',
ja: '準備完了',
ko: '준비됨',
tr: 'Hazır',
uk: 'Готово',
cs: 'Připraveno',
sk: 'Pripravené',
hu: 'Kész',
ro: 'Gata',
bg: 'Готово',
el: 'Έτοιμο',
sr: 'Спремно',
hr: 'Spremno',
sl: 'Pripravljeno',
lt: 'Paruošta',
lv: 'Gatavs',
et: 'Valmis',
nl: 'Gereed',
sv: 'Klar',
da: 'Klar',
fi: 'Valmis',
no: 'Klar',
ar: 'جاهز',
hi: 'तैयार',
bn: 'প্রস্তুত'
},
'simulation.status.stopped': {
es: 'Detenido',
fr: 'Arrêté',
de: 'Gestoppt',
it: 'Arrestato',
pt: 'Parado',
'pt-br': 'Parado',
ru: 'Остановлено',
zh: '已停止',
ja: '停止',
ko: '중지됨',
tr: 'Durduruldu',
uk: 'Зупинено',
cs: 'Zastaveno',
sk: 'Zastavené',
hu: 'Leállítva',
ro: 'Oprit',
bg: 'Спряно',
el: 'Διακοπή',
sr: 'Заустављено',
hr: 'Zaustavljeno',
sl: 'Ustavljeno',
lt: 'Sustabdyta',
lv: 'Apturēts',
et: 'Peatatud',
nl: 'Gestopt',
sv: 'Stoppad',
da: 'Stoppet',
fi: 'Pysäytetty',
no: 'Stoppet',
ar: 'متوقف',
hi: 'रोका गया',
bn: 'বন্ধ'
},
'simulation.status.completed': {
es: 'Completado',
fr: 'Terminé',
de: 'Abgeschlossen',
it: 'Completato',
pt: 'Concluído',
'pt-br': 'Concluído',
ru: 'Завершено',
zh: '已完成',
ja: '完了',
ko: '완료됨',
tr: 'Tamamlandı',
uk: 'Завершено',
cs: 'Dokončeno',
sk: 'Dokončené',
hu: 'Befejezve',
ro: 'Finalizat',
bg: 'Завършено',
el: 'Ολοκληρώθηκε',
sr: 'Завршено',
hr: 'Dovršeno',
sl: 'Dokončano',
lt: 'Baigta',
lv: 'Pabeigts',
et: 'Lõpetatud',
nl: 'Voltooid',
sv: 'Slutförd',
da: 'Fuldført',
fi: 'Valmis',
no: 'Fullført',
ar: 'مكتمل',
hi: 'पूर्ण',
bn: 'সম্পন্ন'
},
'simulation.status.simulating': {
es: 'Simulando {size}x{size} @ {density}%',
fr: 'Simulation de {size}x{size} à {density}%',
de: 'Simuliere {size}x{size} @ {density}%',
it: 'Simulazione {size}x{size} @ {density}%',
pt: 'Simulando {size}x{size} @ {density}%',
'pt-br': 'Simulando {size}x{size} @ {density}%',
ru: 'Симуляция {size}x{size} @ {density}%',
zh: '正在模拟 {size}x{size} @ {density}%',
ja: '{size}x{size} @ {density}% をシミュレーション中',
ko: '{size}x{size} @ {density}% 시뮬레이션 중',
tr: '{size}x{size} @ {density}% simüle ediliyor',
uk: 'Симулювання {size}x{size} @ {density}%',
cs: 'Simulace {size}x{size} @ {density}%',
sk: 'Simulácia {size}x{size} @ {density}%',
hu: 'Szimulálás {size}x{size} @ {density}%',
ro: 'Simulare {size}x{size} @ {density}%',
bg: 'Симулиране {size}x{size} @ {density}%',
el: 'Προσομοίωση {size}x{size} @ {density}%',
sr: 'Симулирање {size}x{size} @ {density}%',
hr: 'Simulacija {size}x{size} @ {density}%',
sl: 'Simulacija {size}x{size} @ {density}%',
lt: 'Simuliuojama {size}x{size} @ {density}%',
lv: 'Simulācija {size}x{size} @ {density}%',
et: 'Simuleerimine {size}x{size} @ {density}%',
nl: 'Simuleren {size}x{size} @ {density}%',
sv: 'Simulerar {size}x{size} @ {density}%',
da: 'Simulerer {size}x{size} @ {density}%',
fi: 'Simulointi {size}x{size} @ {density}%',
no: 'Simulerer {size}x{size} @ {density}%',
ar: 'محاكاة {size}x{size} @ {density}%',
hi: '{size}x{size} @ {density}% का सिमुलेशन',
bn: '{size}x{size} @ {density}% সিমুলেট করা হচ্ছে'
},
'simulation.start': {
es: 'Iniciar simulación',
fr: 'Démarrer la simulation',
de: 'Simulation starten',
it: 'Avvia simulazione',
pt: 'Iniciar simulação',
'pt-br': 'Iniciar simulação',
ru: 'Начать симуляцию',
zh: '开始模拟',
ja: 'シミュレーション開始',
ko: '시뮬레이션 시작',
tr: 'Simülasyonu başlat',
uk: 'Почати симуляцію',
cs: 'Spustit simulaci',
sk: 'Spustiť simuláciu',
hu: 'Szimuláció indítása',
ro: 'Pornește simularea',
bg: 'Стартирай симулация',
el: 'Έναρξη προσομοίωσης',
sr: 'Покрени симулацију',
hr: 'Pokreni simulaciju',
sl: 'Zaženi simulacijo',
lt: 'Pradėti simuliaciją',
lv: 'Sākt simulāciju',
et: 'Alusta simulatsiooni',
nl: 'Simulatie starten',
sv: 'Starta simulering',
da: 'Start simulering',
fi: 'Aloita simulointi',
no: 'Start simulering',
ar: 'بدء المحاكاة',
hi: 'सिमुलेशन शुरू करें',
bn: 'সিমুলেশন শুরু'
},
'simulation.stop': {
es: 'Detener',
fr: 'Arrêter',
de: 'Stoppen',
it: 'Stop',
pt: 'Parar',
'pt-br': 'Parar',
ru: 'Стоп',
zh: '停止',
ja: '停止',
ko: '중지',
tr: 'Durdur',
uk: 'Зупинити',
cs: 'Zastavit',
sk: 'Zastaviť',
hu: 'Leállítás',
ro: 'Oprește',
bg: 'Спри',
el: 'Διακοπή',
sr: 'Заустави',
hr: 'Zaustavi',
sl: 'Ustavi',
lt: 'Stabdyti',
lv: 'Apturēt',
et: 'Peata',
nl: 'Stoppen',
sv: 'Stoppa',
da: 'Stop',
fi: 'Pysäytä',
no: 'Stopp',
ar: 'إيقاف',
hi: 'रोकें',
bn: 'বন্ধ করুন'
},
'simulation.table.size': {
es: 'Tamaño',
fr: 'Taille',
de: 'Größe',
it: 'Dimensione',
pt: 'Tamanho',
'pt-br': 'Tamanho',
ru: 'Размер',
zh: '大小',
ja: 'サイズ',
ko: '크기',
tr: 'Boyut',
uk: 'Розмір',
cs: 'Velikost',
sk: 'Veľkosť',
hu: 'Méret',
ro: 'Dimensiune',
bg: 'Размер',
el: 'Μέγεθος',
sr: 'Величина',
hr: 'Veličina',
sl: 'Velikost',
lt: 'Dydis',
lv: 'Izmērs',
et: 'Suurus',
nl: 'Grootte',
sv: 'Storlek',
da: 'Størrelse',
fi: 'Koko',
no: 'Størrelse',
ar: 'الحجم',
hi: 'आकार',
bn: 'আকার'
},
'simulation.table.density': {
es: 'Densidad',
fr: 'Densité',
de: 'Dichte',
it: 'Densità',
pt: 'Densidade',
'pt-br': 'Densidade',
ru: 'Плотность',
zh: '密度',
ja: '密度',
ko: '밀도',
tr: 'Yoğunluk',
uk: 'Щільність',
cs: 'Hustota',
sk: 'Hustota',
hu: 'Sűrűség',
ro: 'Densitate',
bg: 'Плътност',
el: 'Πυκνότητα',
sr: 'Густина',
hr: 'Gustoća',
sl: 'Gostota',
lt: 'Tankis',
lv: 'Blīvums',
et: 'Tihedus',
nl: 'Dichtheid',
sv: 'Densitet',
da: 'Densitet',
fi: 'Tiheys',
no: 'Tetthet',
ar: 'الكثافة',
hi: 'घनत्व',
bn: 'ঘনত্ব'
},
'simulation.table.solved': {
es: 'Resuelto (Lógica)',
fr: 'Résolu (Logique)',
de: 'Gelöst (Logik)',
it: 'Risolto (Logica)',
pt: 'Resolvido (Lógica)',
'pt-br': 'Resolvido (Lógica)',
ru: 'Решено (Логика)',
zh: '已解(逻辑)',
ja: '解決(ロジック)',
ko: '해결됨(논리)',
tr: 'Çözüldü (Mantık)',
uk: 'Розв’язано (Логіка)',
cs: 'Vyřešeno (Logika)',
sk: 'Vyriešené (Logika)',
hu: 'Megoldva (Logika)',
ro: 'Rezolvat (Logică)',
bg: 'Решено (Логика)',
el: 'Επιλύθηκε (Λογική)',
sr: 'Решено (Логика)',
hr: 'Riješeno (Logika)',
sl: 'Rešeno (Logika)',
lt: 'Išspręsta (Logika)',
lv: 'Atrisināts (Loģika)',
et: 'Lahendatud (Loogika)',
nl: 'Opgelost (Logica)',
sv: 'Löst (Logik)',
da: 'Løst (Logik)',
fi: 'Ratkaistu (Logiikka)',
no: 'Løst (Logikk)',
ar: 'تم الحل (منطق)',
hi: 'हल (तर्क)',
bn: 'সমাধান (লজিক)'
},
'simulation.empty': {
es: 'Pulsa Iniciar para ejecutar la simulación Monte Carlo',
fr: 'Appuyez sur Démarrer pour lancer la simulation Monte Carlo',
de: 'Drücke Start, um die Monte-Carlo-Simulation zu starten',
it: 'Premi Avvia per eseguire la simulazione Monte Carlo',
pt: 'Pressione Iniciar para executar a simulação de Monte Carlo',
'pt-br': 'Pressione Iniciar para executar a simulação de Monte Carlo',
ru: 'Нажмите «Старт», чтобы запустить моделирование Монте‑Карло',
zh: '点击开始运行蒙特卡罗模拟',
ja: 'Monte Carlo シミュレーションを実行するには開始を押してください',
ko: 'Monte Carlo 시뮬레이션을 실행하려면 시작을 누르세요',
tr: 'Monte Carlo simülasyonunu çalıştırmak için Başlata basın',
uk: 'Натисніть «Почати», щоб запустити симуляцію Монте‑Карло',
cs: 'Stiskněte Start pro spuštění simulace Monte Carlo',
sk: 'Stlačte Štart pre spustenie simulácie Monte Carlo',
hu: 'Nyomd meg a Startot a Monte Carlo szimulációhoz',
ro: 'Apasă Start pentru a rula simularea Monte Carlo',
bg: 'Натисни Старт, за да стартираш симулация Монте Карло',
el: 'Πατήστε Έναρξη για να τρέξετε προσομοίωση Monte Carlo',
sr: 'Притисни Старт да покренеш Монте Карло симулацију',
hr: 'Pritisni Start za pokretanje Monte Carlo simulacije',
sl: 'Pritisnite Start za zagon simulacije Monte Carlo',
lt: 'Paspauskite Start, kad paleistumėte Monte Karlo simuliaciją',
lv: 'Nospiediet Start, lai palaistu Monte Carlo simulāciju',
et: 'Vajuta Start, et käivitada Monte Carlo simulatsioon',
nl: 'Druk op Start om de Monte Carlo-simulatie te starten',
sv: 'Tryck Start för att köra Monte Carlo-simuleringen',
da: 'Tryk Start for at køre Monte Carlo-simuleringen',
fi: 'Paina Käynnistä aloittaaksesi Monte Carlo -simulaation',
no: 'Trykk Start for å kjøre Monte Carlo-simuleringen',
ar: 'اضغط ابدأ لتشغيل محاكاة مونتِ كارلو',
hi: 'मोंटे कार्लो सिमुलेशन चलाने के लिए स्टार्ट दबाएँ',
bn: 'মোন্টে কার্লো সিমুলেশন চালাতে স্টার্ট চাপুন'
},
'custom.simulationHelp': {
es: '¿Cómo se calcula?',
fr: 'Comment est-ce calculé ?',
de: 'Wie wird das berechnet?',
it: 'Come viene calcolato?',
pt: 'Como isso é calculado?',
'pt-br': 'Como isso é calculado?',
ru: 'Как это рассчитывается?',
zh: '这是如何计算的?',
ja: 'これはどのように計算されますか?',
ko: '이것은 어떻게 계산됩니까?',
tr: 'Bu nasıl hesaplanıyor?',
uk: 'Як це обчислюється?',
cs: 'Jak se to počítá?',
sk: 'Ako sa to počíta?',
hu: 'Hogyan számoljuk?',
ro: 'Cum este calculat?',
bg: 'Как се изчислява?',
el: 'Πώς υπολογίζεται;',
sr: 'Како се израчунава?',
hr: 'Kako se izračunava?',
sl: 'Kako je izračunano?',
lt: 'Kaip tai apskaičiuojama?',
lv: 'Kā tas tiek aprēķināts?',
et: 'Kuidas see arvutatakse?',
nl: 'Hoe wordt dit berekend?',
sv: 'Hur beräknas detta?',
da: 'Hvordan beregnes dette?',
fi: 'Miten tämä lasketaan?',
no: 'Hvordan beregnes dette?',
ar: 'كيف يتم احتساب ذلك؟',
hi: 'यह कैसे गणना किया जाता है?',
bn: 'এটি কীভাবে গণনা করা হয়?'
}
};
function replaceInLanguage(lang, key, value) {
const langStart = new RegExp(`\\s{2}['\"]?${lang}['\"]?\\s*:\\s*\\{`);
const index = content.search(langStart);
if (index === -1) return;
const start = content.indexOf('{', index);
let depth = 1;
let i = start + 1;
while (i < content.length && depth > 0) {
if (content[i] === '{') depth++;
else if (content[i] === '}') depth--;
i++;
}
const block = content.slice(start + 1, i - 1);
let newBlock;
const keyRegex = new RegExp(`(['\"])${key}\\1\\s*:\\s*(['\"]).*?\\2`);
if (keyRegex.test(block)) {
newBlock = block.replace(keyRegex, `'${key}': '${value}'`);
} else {
newBlock = block.trim().endsWith(',') ? block + `\n '${key}': '${value}'` : block + `,\n '${key}': '${value}'`;
}
content = content.slice(0, start + 1) + newBlock + content.slice(i - 1);
}
Object.entries(keys).forEach(([key, translations]) => {
Object.entries(translations).forEach(([lang, value]) => {
replaceInLanguage(lang, key, value);
});
});
fs.writeFileSync(path, content);
console.log('Translations updated.');

View File

@@ -8,6 +8,7 @@ import StatusPanel from './components/StatusPanel.vue';
import GuidePanel from './components/GuidePanel.vue'; import GuidePanel from './components/GuidePanel.vue';
import WinModal from './components/WinModal.vue'; import WinModal from './components/WinModal.vue';
import CustomGameModal from './components/CustomGameModal.vue'; import CustomGameModal from './components/CustomGameModal.vue';
import ImageImportModal from './components/ImageImportModal.vue';
import SimulationView from './components/SimulationView.vue'; import SimulationView from './components/SimulationView.vue';
import FixedBar from './components/FixedBar.vue'; import FixedBar from './components/FixedBar.vue';
import ReloadPrompt from './components/ReloadPrompt.vue'; import ReloadPrompt from './components/ReloadPrompt.vue';
@@ -16,6 +17,7 @@ import ReloadPrompt from './components/ReloadPrompt.vue';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n(); const { t, locale, setLocale, locales } = useI18n();
const showCustomModal = ref(false); const showCustomModal = ref(false);
const showImageImportModal = ref(false);
const showSimulation = ref(false); const showSimulation = ref(false);
const showGuide = ref(false); const showGuide = ref(false);
const deferredPrompt = ref(null); const deferredPrompt = ref(null);
@@ -23,11 +25,32 @@ const canInstall = ref(false);
const installDismissed = ref(false); const installDismissed = ref(false);
const isCoarsePointer = ref(false); const isCoarsePointer = ref(false);
const isStandalone = ref(false); const isStandalone = ref(false);
const isIos = ref(false);
const isDev = ref(false);
const themePreference = ref('system'); const themePreference = ref('system');
const appVersion = __APP_VERSION__; const appVersion = __APP_VERSION__;
let displayModeMedia = null; let displayModeMedia = null;
let prefersColorSchemeMedia = null; let prefersColorSchemeMedia = null;
const onKeyDownGlobal = (e) => {
if (e.key !== 'Escape') return;
if (showSimulation.value) {
showSimulation.value = false;
return;
}
if (showCustomModal.value) {
showCustomModal.value = false;
return;
}
if (showImageImportModal.value) {
showImageImportModal.value = false;
return;
}
if (store.isGameWon) {
store.closeWinModal();
}
};
const installLabel = computed(() => { const installLabel = computed(() => {
return isCoarsePointer.value ? t('pwa.installMobile') : t('pwa.installDesktop'); return isCoarsePointer.value ? t('pwa.installMobile') : t('pwa.installDesktop');
}); });
@@ -43,7 +66,7 @@ const updateStandalone = () => {
const handleBeforeInstallPrompt = (e) => { const handleBeforeInstallPrompt = (e) => {
e.preventDefault(); e.preventDefault();
deferredPrompt.value = e; deferredPrompt.value = e;
if (!isStandalone.value) { if (!isStandalone.value && !isDev.value) {
canInstall.value = true; canInstall.value = true;
} }
}; };
@@ -94,6 +117,9 @@ onMounted(() => {
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
isCoarsePointer.value = window.matchMedia('(pointer: coarse)').matches; isCoarsePointer.value = window.matchMedia('(pointer: coarse)').matches;
const ua = navigator.userAgent.toLowerCase();
isIos.value = /ipad|iphone|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1);
isDev.value = window.location.port !== '' && window.location.port !== '80' && window.location.port !== '443';
const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null; const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') { if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') {
themePreference.value = storedTheme; themePreference.value = storedTheme;
@@ -114,6 +140,7 @@ onMounted(() => {
} else if (displayModeMedia?.addListener) { } else if (displayModeMedia?.addListener) {
displayModeMedia.addListener(updateStandalone); displayModeMedia.addListener(updateStandalone);
} }
window.addEventListener('keydown', onKeyDownGlobal);
} }
}); });
@@ -131,6 +158,7 @@ onUnmounted(() => {
} else if (displayModeMedia?.removeListener) { } else if (displayModeMedia?.removeListener) {
displayModeMedia.removeListener(updateStandalone); displayModeMedia.removeListener(updateStandalone);
} }
window.removeEventListener('keydown', onKeyDownGlobal);
}); });
</script> </script>
@@ -138,18 +166,30 @@ onUnmounted(() => {
<main class="game-container"> <main class="game-container">
<NavBar <NavBar
@open-custom="showCustomModal = true" @open-custom="showCustomModal = true"
@open-image-import="showImageImportModal = true"
@toggle-guide="showGuide = !showGuide" @toggle-guide="showGuide = !showGuide"
@set-theme="setThemePreference" @set-theme="setThemePreference"
/> />
<FixedBar /> <FixedBar />
<div v-if="canInstall && !installDismissed" class="install-banner"> <div v-if="!isDev && (canInstall || (isIos && !isStandalone)) && !installDismissed" class="install-banner">
<div class="install-text">{{ t('pwa.installTitle') }}</div> <div class="install-content">
<img src="/pwa-192x192.png" alt="App Icon" class="install-icon" />
<div class="install-text">
<div class="install-title">{{ t('app.title') }}</div>
<div class="install-desc">
<span v-if="isIos && !isStandalone">{{ t('pwa.installIos') }}</span>
<span v-else>{{ t('pwa.installTitle') }}</span>
</div>
</div>
</div>
<div class="install-actions"> <div class="install-actions">
<button class="btn-neon secondary install-btn" @click="handleInstall"> <button v-if="!isIos" class="btn-neon secondary install-btn" @click="handleInstall">
{{ installLabel }} {{ installLabel }}
</button> </button>
<button class="install-close" @click="installDismissed = true">×</button> <button class="install-close" @click="installDismissed = true" aria-label="Close">
</button>
</div> </div>
</div> </div>
@@ -176,6 +216,10 @@ onUnmounted(() => {
<Teleport to="body"> <Teleport to="body">
<WinModal v-if="store.isGameWon" /> <WinModal v-if="store.isGameWon" />
<CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" @open-simulation="showSimulation = true" /> <CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" @open-simulation="showSimulation = true" />
<ImageImportModal
v-if="showImageImportModal"
@close="showImageImportModal = false"
/>
<SimulationView v-if="showSimulation" @close="showSimulation = false" /> <SimulationView v-if="showSimulation" @close="showSimulation = false" />
<ReloadPrompt /> <ReloadPrompt />
</Teleport> </Teleport>
@@ -186,7 +230,7 @@ onUnmounted(() => {
.game-container { .game-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; /* was center */ align-items: stretch;
gap: 20px; gap: 20px;
width: 100%; width: 100%;
padding-bottom: 50px; padding-bottom: 50px;
@@ -194,38 +238,111 @@ onUnmounted(() => {
} }
.install-banner { .install-banner {
background: var(--banner-bg); position: fixed;
border: 1px solid var(--banner-border); bottom: 0;
border-radius: 8px;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
width: 90%;
max-width: 600px;
margin: 0 auto 20px auto; /* Center it manually */
box-shadow: var(--banner-shadow);
position: sticky;
left: 0; left: 0;
right: 0; width: 100%;
background: var(--panel-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid var(--neon-cyan);
box-shadow: 0 -4px 30px rgba(0, 242, 254, 0.15);
padding: 16px 20px;
padding-bottom: max(16px, env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 16px;
z-index: 2000;
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (min-width: 768px) {
.install-banner {
width: 400px;
bottom: 24px;
right: 24px;
left: auto;
border-radius: 12px;
border: 1px solid var(--neon-cyan);
padding-bottom: 16px;
}
}
.install-content {
display: flex;
align-items: center;
gap: 16px;
}
.install-icon {
width: 48px;
height: 48px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
} }
.install-text { .install-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.install-title {
font-weight: bold;
color: var(--neon-cyan);
font-size: 1.1rem;
}
.install-desc {
font-size: 0.9rem;
color: var(--text-color); color: var(--text-color);
line-height: 1.3;
} }
.install-actions { .install-actions {
display: flex; display: flex;
gap: 10px;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px;
}
.install-btn {
flex: 1;
padding: 10px;
font-size: 0.95rem;
justify-content: center;
} }
.install-close { .install-close {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-muted); color: var(--text-muted);
font-size: 1.5rem; font-size: 1.2rem;
cursor: pointer; cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.install-close:hover {
background: rgba(255,255,255,0.1);
color: var(--text-color);
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
.game-layout { .game-layout {

View File

@@ -43,21 +43,30 @@ const handlePointerDown = (e) => {
const now = Date.now(); const now = Date.now();
if (now - lastTap < 300) { if (now - lastTap < 300) {
// Double tap -> X (Force) // Double tap -> X (Force)
clearLongPress();
emit('start-drag', props.r, props.c, true, true); emit('start-drag', props.r, props.c, true, true);
lastTap = 0; lastTap = 0;
} else { } else {
// Single tap / Start drag -> Fill // Single tap / Start drag -> Fill
emit('start-drag', props.r, props.c, false, false); emit('start-drag', props.r, props.c, false, false);
lastTap = now; lastTap = now;
// Start Long Press Timer
clearLongPress();
longPressTimer = setTimeout(() => {
if (navigator.vibrate) navigator.vibrate(50);
// Switch to Cross (Right click logic, force=true to overwrite the just-placed Fill)
emit('start-drag', props.r, props.c, true, true);
}, 500);
} }
}; };
const handlePointerUp = (e) => { const handlePointerUp = (e) => {
// Handled in pointerdown clearLongPress();
}; };
const handlePointerCancel = (e) => { const handlePointerCancel = (e) => {
// Handled in pointerdown clearLongPress();
}; };
</script> </script>
@@ -84,6 +93,7 @@ const handlePointerCancel = (e) => {
height: var(--cell-size); height: var(--cell-size);
background-color: var(--cell-empty); background-color: var(--cell-empty);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-sizing: border-box;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -340,12 +340,20 @@ const confirm = () => {
text-align: center; text-align: center;
max-width: 800px; max-width: 800px;
width: 90%; width: 90%;
max-height: 90vh;
overflow-y: auto;
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; transition: all 0.3s ease-in-out;
} }
@media (max-width: 768px), (max-height: 600px) {
.modal {
padding: 20px;
}
}
.modal-content { .modal-content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -10,6 +10,10 @@ const store = usePuzzleStore();
const { rowHints, colHints } = useHints(computed(() => store.solution)); const { rowHints, colHints } = useHints(computed(() => store.solution));
const { startDrag, onMouseEnter, stopDrag } = useNonogram(); const { startDrag, onMouseEnter, stopDrag } = useNonogram();
// Compute grid dimensions from hints
const gridRows = computed(() => rowHints.value.length);
const gridCols = computed(() => colHints.value.length);
const cellSize = ref(30); const cellSize = ref(30);
const rowHintsRef = ref(null); const rowHintsRef = ref(null);
const activeRow = ref(null); const activeRow = ref(null);
@@ -27,17 +31,28 @@ let dragStartLeft = 0;
const checkScroll = () => { const checkScroll = () => {
const el = scrollWrapper.value; const el = scrollWrapper.value;
if (!el) return; if (!el) return;
const content = el.firstElementChild;
const contentWidth = content ? content.offsetWidth : el.scrollWidth;
const sw = el.scrollWidth; const sw = el.scrollWidth;
const cw = el.clientWidth; const cw = el.clientWidth;
// Only show custom scrollbar on mobile/tablet (width < 768px) and if content overflows // Only show custom scrollbar on mobile/tablet (width < 768px) and if content overflows
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
showScrollbar.value = isMobile && (sw > cw + 1); // Use contentWidth to check for overflow, as scrollWidth might be misleading
showScrollbar.value = isMobile && (contentWidth > cw + 1);
if (showScrollbar.value) { if (showScrollbar.value) {
// Thumb width percentage = (viewport / total) * 100 // Thumb width percentage = (viewport / total) * 100
const ratio = cw / sw; // Use contentWidth for more accurate ratio
const ratio = cw / contentWidth;
thumbWidth.value = Math.max(10, ratio * 100); thumbWidth.value = Math.max(10, ratio * 100);
// Hide if content fits or almost fits (prevent useless scrollbar)
// Increased tolerance to 95%
if (ratio >= 0.95) {
showScrollbar.value = false;
}
} }
}; };
@@ -132,13 +147,16 @@ const computeCellSize = () => {
// Ensure we don't have negative space // Ensure we don't have negative space
const availableForGrid = Math.max(0, containerWidth - hintWidth); const availableForGrid = Math.max(0, containerWidth - hintWidth);
const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size); // Calculate cell size based on width availability (columns)
// Vertical scrolling is acceptable, so we don't constrain by height (rows)
const cols = Math.max(1, gridCols.value);
const size = Math.floor((availableForGrid - gridPad * 2 - (cols - 1) * gap) / cols);
if (isDesktop) { if (isDesktop) {
// Desktop: Allow overflow, use comfortable size // Desktop: Allow overflow, use comfortable size
cellSize.value = 30; cellSize.value = 30;
} else { } else {
// Mobile: Fit to screen // Mobile: Fit to screen width
// Keep min 18, max 36 // Keep min 18, max 36
cellSize.value = Math.max(18, Math.min(36, size)); cellSize.value = Math.max(18, Math.min(36, size));
} }
@@ -175,21 +193,38 @@ const handleGridLeave = () => {
activeCol.value = null; activeCol.value = null;
}; };
const handleResize = () => {
computeCellSize();
checkScroll();
// Re-check after potential layout animation/transition
setTimeout(() => {
computeCellSize();
checkScroll();
}, 300);
};
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
computeCellSize(); computeCellSize();
checkScroll();
// Extra check for slow layout/font loading or orientation changes
setTimeout(() => {
computeCellSize();
checkScroll();
}, 300);
}); });
isFinePointer.value = window.matchMedia('(pointer: fine)').matches; isFinePointer.value = window.matchMedia('(pointer: fine)').matches;
window.addEventListener('resize', computeCellSize);
window.addEventListener('resize', checkScroll); window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
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 });
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', computeCellSize); window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', checkScroll); window.removeEventListener('orientationchange', handleResize);
window.removeEventListener('mouseup', handleGlobalMouseUp); window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('pointerup', handleGlobalPointerUp); window.removeEventListener('pointerup', handleGlobalPointerUp);
window.removeEventListener('touchend', handleGlobalPointerUp); window.removeEventListener('touchend', handleGlobalPointerUp);
@@ -199,6 +234,10 @@ watch(() => store.size, async () => {
await nextTick(); await nextTick();
computeCellSize(); computeCellSize();
checkScroll(); checkScroll();
setTimeout(() => {
computeCellSize();
checkScroll();
}, 300);
}); });
</script> </script>
@@ -208,17 +247,17 @@ watch(() => store.size, async () => {
<div class="corner-spacer"></div> <div class="corner-spacer"></div>
<!-- Column Hints --> <!-- Column Hints -->
<Hints :hints="colHints" orientation="col" :size="store.size" :activeIndex="activeCol" /> <Hints :hints="colHints" orientation="col" :size="gridCols" :activeIndex="activeCol" />
<!-- Row Hints --> <!-- Row Hints -->
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="store.size" :activeIndex="activeRow" /> <Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="gridRows" :activeIndex="activeRow" />
<!-- Grid --> <!-- Grid -->
<div <div
class="grid" class="grid"
:style="{ :style="{
gridTemplateColumns: `repeat(${store.size}, var(--cell-size))`, gridTemplateColumns: `repeat(${gridCols}, var(--cell-size))`,
gridTemplateRows: `repeat(${store.size}, var(--cell-size))` gridTemplateRows: `repeat(${gridRows}, var(--cell-size))`
}" }"
@pointermove.prevent="handlePointerMove" @pointermove.prevent="handlePointerMove"
@mouseleave="handleGridLeave" @mouseleave="handleGridLeave"
@@ -231,8 +270,8 @@ watch(() => store.size, async () => {
:r="r" :r="r"
:c="c" :c="c"
:class="{ :class="{
'guide-right': (c + 1) % 5 === 0 && c !== store.size - 1, 'guide-right': (c + 1) % 5 === 0 && c !== gridCols - 1,
'guide-bottom': (r + 1) % 5 === 0 && r !== store.size - 1 'guide-bottom': (r + 1) % 5 === 0 && r !== gridRows - 1
}" }"
@start-drag="startDrag" @start-drag="startDrag"
@enter-cell="handleCellEnter" @enter-cell="handleCellEnter"
@@ -272,6 +311,7 @@ watch(() => store.size, async () => {
min-width: 100%; min-width: 100%;
margin: 0 auto; /* Center the wrapper safely */ margin: 0 auto; /* Center the wrapper safely */
align-items: flex-start; /* Prevent cropping when centered */ align-items: flex-start; /* Prevent cropping when centered */
padding-right: 40px;
} }
.game-container { .game-container {

View File

@@ -32,7 +32,11 @@ defineProps({
v-for="(group, index) in hints" v-for="(group, index) in hints"
:key="index" :key="index"
class="hint-group" class="hint-group"
:class="{ 'is-active': index === activeIndex }" :class="{
'is-active': index === activeIndex,
'guide-right': orientation === 'col' && (index + 1) % 5 === 0 && index !== size - 1,
'guide-bottom': orientation === 'row' && (index + 1) % 5 === 0 && index !== size - 1
}"
> >
<span <span
v-for="(num, idx) in group" v-for="(num, idx) in group"
@@ -54,13 +58,13 @@ defineProps({
.hints-container.col { .hints-container.col {
padding-bottom: var(--grid-padding); padding-bottom: var(--grid-padding);
align-items: flex-end; /* align-items: flex-end; - Removed to ensure uniform column height */
padding-left: var(--grid-padding); padding-left: var(--grid-padding);
padding-right: var(--grid-padding); padding-right: var(--grid-padding);
} }
.hints-container.row { .hints-container.row {
align-items: flex-end; /* align-items: flex-end; - Removed to ensure row hints fill the cell height */
padding: var(--grid-padding) var(--grid-padding) var(--grid-padding) 0; padding: var(--grid-padding) var(--grid-padding) var(--grid-padding) 0;
width: max-content; width: max-content;
} }
@@ -95,6 +99,21 @@ defineProps({
padding: 2px; padding: 2px;
} }
@media (max-width: 768px) {
.hint-num {
font-size: 0.7rem;
padding: 1px;
}
.col .hint-group {
padding: 2px 0;
}
.row .hint-group {
padding: 0 4px;
}
}
/* Alternating Colors within the group */ /* Alternating Colors within the group */
.hint-num.hint-alt { .hint-num.hint-alt {
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -111,4 +130,12 @@ defineProps({
border-color: rgba(79, 172, 254, 0.8); border-color: rgba(79, 172, 254, 0.8);
box-shadow: 0 0 12px rgba(79, 172, 254, 0.35); box-shadow: 0 0 12px rgba(79, 172, 254, 0.35);
} }
/* Guide lines every 5 */
.hint-group.guide-right {
border-right: 2px solid rgba(0, 242, 255, 0.5);
}
.hint-group.guide-bottom {
border-bottom: 2px solid rgba(0, 242, 255, 0.5);
}
</style> </style>

View File

@@ -0,0 +1,859 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
import { getWorkerPool } from '@/utils/workerPool';
import { Upload, Image as ImageIcon, X, AlertTriangle, Camera, RefreshCw } from 'lucide-vue-next';
const emit = defineEmits(['close']);
const store = usePuzzleStore();
const { t } = useI18n();
const fileInput = ref(null);
const canvasRef = ref(null);
const previewCanvasRef = ref(null);
const videoRef = ref(null);
const isDragging = ref(false);
const imageLoaded = ref(false);
const processing = ref(false);
const processingProgress = ref(0);
const isCameraOpen = ref(false);
const hasMultipleCameras = ref(false);
const stream = ref(null);
const facingMode = ref('environment');
// Settings
const maxDimension = ref(15);
const threshold = ref(50); // 0-100% density
// State
const originalImage = ref(null);
const generatedGrid = ref([]);
const difficulty = ref(0);
const solvability = ref(0);
const difficultyLabel = ref('');
const gridRows = ref(15);
const gridCols = ref(15);
const onDrop = (e) => {
isDragging.value = false;
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
processFile(file);
}
};
const onFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
processFile(file);
}
};
const processFile = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
originalImage.value = img;
imageLoaded.value = true;
nextTick(() => {
updateGrid();
});
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
const updateGrid = () => {
if (!originalImage.value || !canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
// Calculate dimensions preserving aspect ratio
const imgW = originalImage.value.width;
const imgH = originalImage.value.height;
const aspect = imgW / imgH;
let w, h;
const maxDim = maxDimension.value;
if (aspect >= 1) {
w = maxDim;
h = Math.round(w / aspect);
} else {
h = maxDim;
w = Math.round(h * aspect);
}
// Ensure bounds [5, 80]
// 1. Clamp min (prioritize min 5)
if (w < 5) { w = 5; h = Math.round(w / aspect); }
if (h < 5) { h = 5; w = Math.round(h * aspect); }
// 2. Clamp max (hard limit 80)
if (w > 80) w = 80;
if (h > 80) h = 80;
// Final safeguard
w = Math.max(5, Math.min(80, Math.round(w)));
h = Math.max(5, Math.min(80, Math.round(h)));
gridRows.value = h;
gridCols.value = w;
// Resize logic
canvasRef.value.width = w;
canvasRef.value.height = h;
// Draw image scaled to grid size
ctx.drawImage(originalImage.value, 0, 0, w, h);
// Get pixel data
const imageData = ctx.getImageData(0, 0, w, h);
const data = imageData.data;
// 1. Collect brightness of valid pixels
const pixels = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
// If transparent, ignore
if (a >= 128) {
// Calculate brightness (Luminance)
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
pixels.push({ x, y, brightness });
}
}
}
// 2. Sort by brightness (ascending: dark -> light)
// Darker pixels have lower brightness.
// We want to fill the darkest X% pixels.
pixels.sort((a, b) => a.brightness - b.brightness);
// 3. Determine cutoff
// threshold.value is density (0-100%)
// If density is 50%, we want 50% of pixels to be black.
// So we take the first 50% of sorted pixels.
const totalValid = pixels.length;
const targetFillCount = Math.floor(totalValid * (threshold.value / 100));
// 4. Build grid
const grid = Array(h).fill().map(() => Array(w).fill(0));
// Mark the darkest pixels as filled (1)
for (let i = 0; i < targetFillCount; i++) {
const p = pixels[i];
grid[p.y][p.x] = 1;
}
generatedGrid.value = grid;
drawPreview();
calculateStats();
};
const drawPreview = () => {
if (!previewCanvasRef.value || generatedGrid.value.length === 0) return;
const ctx = previewCanvasRef.value.getContext('2d');
const rows = generatedGrid.value.length;
const cols = generatedGrid.value[0].length;
// Max 300px width/height, preserving aspect
const maxPreview = 300;
const cellW = maxPreview / Math.max(rows, cols);
const cellH = cellW; // square cells
const previewW = cols * cellW;
const previewH = rows * cellH;
previewCanvasRef.value.width = previewW;
previewCanvasRef.value.height = previewH;
ctx.clearRect(0, 0, previewW, previewH);
// Draw original image with low opacity
if (originalImage.value) {
ctx.save();
ctx.globalAlpha = 0.3;
ctx.drawImage(originalImage.value, 0, 0, previewW, previewH);
ctx.restore();
}
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (generatedGrid.value[y][x] === 1) {
ctx.fillStyle = '#000';
ctx.fillRect(x * cellW, y * cellH, cellW, cellH);
}
}
}
// Grid lines
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 0.5;
// Vertical lines
for (let i = 0; i <= cols; i++) {
ctx.beginPath();
ctx.moveTo(i * cellW, 0);
ctx.lineTo(i * cellW, previewH);
ctx.stroke();
}
// Horizontal lines
for (let i = 0; i <= rows; i++) {
ctx.beginPath();
ctx.moveTo(0, i * cellH);
ctx.lineTo(previewW, i * cellH);
ctx.stroke();
}
};
const calculateStats = async () => {
if (generatedGrid.value.length === 0) return;
processing.value = true;
processingProgress.value = 0;
const requestId = Date.now();
currentStatsRequestId = requestId;
try {
const pool = getWorkerPool();
pool.cancelAll(); // Force stop previous calculations for immediate responsiveness
// Demonstrate parallel execution capability
// We split the problem into two branches: assuming first cell is EMPTY (0) vs FILLED (1)
// This doubles the search power by utilizing 2 workers immediately.
// Ensure we send plain objects to workers, not Vue proxies
const rawGrid = JSON.parse(JSON.stringify(generatedGrid.value));
const rows = rawGrid.length;
const cols = rawGrid[0].length;
// Create initial states
const gridA = Array(rows).fill().map(() => Array(cols).fill(-1));
gridA[0][0] = 0; // Branch A: Assume (0,0) is Empty
const gridB = Array(rows).fill().map(() => Array(cols).fill(-1));
gridB[0][0] = 1; // Branch B: Assume (0,0) is Filled
const tasks = [
{
data: { id: requestId, grid: rawGrid, initialGrid: gridA },
onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); }
},
{
data: { id: requestId, grid: rawGrid, initialGrid: gridB },
onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); }
}
];
const result = await pool.runRace(tasks);
if (result.id === currentStatsRequestId) {
solvability.value = result.solvability;
difficulty.value = result.difficulty;
difficultyLabel.value = result.difficultyLabel;
}
} catch (err) {
if (err.message !== 'Cancelled' && err.message !== 'Terminated') {
console.error('Worker error:', err);
if (currentStatsRequestId === requestId) {
solvability.value = 0;
difficulty.value = 0;
// If translation key is missing, this might show 'difficulty.error'
// Ensure we have a fallback or the key exists
difficultyLabel.value = 'error';
}
}
} finally {
if (currentStatsRequestId === requestId) {
processing.value = false;
}
}
};
let currentStatsRequestId = 0;
let debounceTimer;
watch([maxDimension, threshold], () => {
if (imageLoaded.value) {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateGrid();
}, 50);
} else {
// If no image loaded, just update the display values
// Assuming square aspect ratio for preview
gridRows.value = maxDimension.value;
gridCols.value = maxDimension.value;
}
});
const createPuzzle = () => {
if (generatedGrid.value.length > 0) {
store.initFromImage(generatedGrid.value);
emit('close');
}
};
const triggerFileInput = () => {
fileInput.value.click();
};
const startCamera = async () => {
isCameraOpen.value = true;
try {
if (stream.value) {
stopCameraStream();
}
const constraints = {
video: {
facingMode: facingMode.value
}
};
stream.value = await navigator.mediaDevices.getUserMedia(constraints);
// Check available devices
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
hasMultipleCameras.value = videoDevices.length > 1;
// Wait for next tick or ensure videoRef is available
setTimeout(() => {
if (videoRef.value) {
videoRef.value.srcObject = stream.value;
}
}, 100);
} catch (err) {
console.error("Error accessing camera:", err);
alert(t('image.cameraError'));
isCameraOpen.value = false;
}
};
const stopCameraStream = () => {
if (stream.value) {
stream.value.getTracks().forEach(track => track.stop());
stream.value = null;
}
};
const closeCamera = () => {
stopCameraStream();
isCameraOpen.value = false;
};
const switchCamera = async () => {
facingMode.value = facingMode.value === 'user' ? 'environment' : 'user';
await startCamera();
};
const capturePhoto = () => {
if (!videoRef.value) return;
const canvas = document.createElement('canvas');
canvas.width = videoRef.value.videoWidth;
canvas.height = videoRef.value.videoHeight;
const ctx = canvas.getContext('2d');
// Mirror if using front camera
if (facingMode.value === 'user') {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
originalImage.value = img;
imageLoaded.value = true;
nextTick(() => {
updateGrid();
});
closeCamera();
};
img.src = canvas.toDataURL('image/png');
};
onUnmounted(() => {
stopCameraStream();
});
</script>
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-content">
<button class="close-btn" @click="$emit('close')">
<X :size="24" />
</button>
<h2 class="modal-title">{{ t('image.title') }}</h2>
<div v-if="isCameraOpen" class="camera-overlay">
<video ref="videoRef" autoplay playsinline></video>
<div class="camera-controls">
<button class="camera-btn secondary" @click="closeCamera">
<X :size="24" />
</button>
<button class="camera-btn capture" @click="capturePhoto">
<div class="shutter"></div>
</button>
<button v-if="hasMultipleCameras" class="camera-btn secondary" @click="switchCamera">
<RefreshCw :size="24" />
</button>
</div>
</div>
<div class="content-grid">
<!-- Left: Input & Preview -->
<div class="input-section">
<div
class="drop-zone"
:class="{ dragging: isDragging, loaded: imageLoaded }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
@click="!imageLoaded ? triggerFileInput() : null"
>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onFileSelect"
/>
<div v-if="!imageLoaded" class="placeholder">
<Upload :size="48" class="icon" />
<p>{{ t('image.drop') }}</p>
<div class="button-group">
<button class="btn-neon small" @click.stop="triggerFileInput">
{{ t('image.select') }}
</button>
<button class="btn-neon small secondary" @click.stop="startCamera">
<Camera :size="16" />
{{ t('image.camera') }}
</button>
</div>
</div>
<div v-else class="preview-container">
<canvas ref="previewCanvasRef" class="preview-canvas"></canvas>
<div class="button-group">
<button class="btn-change" @click.stop="triggerFileInput">
{{ t('image.change') }}
</button>
<button class="btn-change" @click.stop="startCamera">
<Camera :size="16" />
</button>
</div>
</div>
</div>
<!-- Hidden canvas for processing -->
<canvas ref="canvasRef" style="display: none;"></canvas>
</div>
<!-- Right: Controls & Stats -->
<div class="controls-section">
<div class="control-group">
<label>{{ t('image.size') }}: {{ gridCols }}x{{ gridRows }}</label>
<input
type="range"
v-model.number="maxDimension"
min="5"
max="80"
step="5"
/>
</div>
<div class="control-group">
<label>{{ t('image.threshold') }}: {{ threshold }}%</label>
<input
type="range"
v-model.number="threshold"
min="1"
max="99"
/>
<div class="threshold-preview">
<span>Low Density</span>
<span>High Density</span>
</div>
</div>
<div v-if="imageLoaded" class="stats-panel">
<div v-if="processing" class="loading-stats">
<div class="spinner"></div>
<span>{{ t('image.calculatingSolvability') || 'Calculating solvability...' }} {{ processingProgress }}%</span>
</div>
<template v-else>
<div class="stat-row">
<span>{{ t('image.solvability') }}:</span>
<span :class="{ 'good': solvability === 100, 'bad': solvability < 100 }">
{{ solvability }}%
</span>
</div>
<div v-if="solvability < 100" class="warning">
<AlertTriangle :size="16" />
{{ t('image.warning') }}
</div>
<div class="stat-row">
<span>{{ t('image.difficulty') }}:</span>
<span>{{ difficulty }}% ({{ t(`difficulty.${difficultyLabel.toLowerCase()}`) }})</span>
</div>
</template>
</div>
<div class="actions">
<button
class="btn-neon primary"
:disabled="!imageLoaded || processing"
@click="createPuzzle"
>
{{ t('image.create') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.loading-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px;
color: var(--text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--text-muted);
border-top-color: var(--primary-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 30px;
width: 90%;
max-width: 800px;
position: relative;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
color: var(--text-color);
max-height: 90vh;
overflow-y: auto;
}
.close-btn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 0.3s;
}
.close-btn:hover {
color: var(--text-color);
}
.modal-title {
text-align: center;
margin-bottom: 30px;
color: var(--primary-accent);
font-size: 1.8rem;
text-shadow: 0 0 10px var(--primary-accent);
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
}
.camera-overlay video {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-controls {
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
display: flex;
justify-content: space-around;
align-items: center;
padding: 20px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
}
.camera-btn {
background: none;
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(5px);
}
.camera-btn.capture {
width: 70px;
height: 70px;
background: rgba(255,255,255,0.3);
border: 4px solid white;
}
.shutter {
width: 54px;
height: 54px;
background: white;
border-radius: 50%;
transition: transform 0.1s;
}
.camera-btn.capture:active .shutter {
transform: scale(0.9);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
justify-content: center;
}
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
@media (max-width: 768px) {
.content-grid {
grid-template-columns: 1fr;
}
}
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 12px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
cursor: pointer;
transition: all 0.3s;
background: rgba(255, 255, 255, 0.05);
}
.drop-zone:hover, .drop-zone.dragging {
border-color: var(--primary-accent);
background: rgba(0, 242, 254, 0.1);
}
.drop-zone.loaded {
border: none;
background: none;
cursor: default;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
color: var(--text-muted);
}
.hidden-input {
display: none;
}
.preview-container {
position: relative;
width: 300px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
.preview-container .button-group {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 8px;
margin: 0;
}
.preview-canvas {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
box-shadow: 0 0 15px rgba(0,0,0,0.3);
}
.btn-change {
background: rgba(0,0,0,0.7);
color: white;
border: 1px solid white;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
transition: background 0.2s;
}
.btn-change:hover {
background: rgba(0,0,0,0.9);
}
.controls-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 10px;
}
input[type="range"] {
width: 100%;
accent-color: var(--primary-accent);
}
.threshold-preview {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
.stats-panel {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.stat-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 1.1rem;
}
.good { color: #4ade80; }
.bad { color: #f87171; }
.warning {
color: #fbbf24;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 5px;
margin-top: -5px;
margin-bottom: 10px;
}
.actions {
margin-top: auto;
display: flex;
justify-content: center;
}
.btn-neon {
padding: 10px 20px;
border: 1px solid var(--primary-accent);
background: rgba(0, 242, 254, 0.1);
color: var(--primary-accent);
font-family: 'Orbitron', sans-serif;
text-transform: uppercase;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
border-radius: 4px;
}
.btn-neon:hover:not(:disabled) {
background: var(--primary-accent);
color: #000;
box-shadow: 0 0 20px var(--primary-accent);
}
.btn-neon:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
.btn-neon.small {
padding: 5px 15px;
font-size: 0.8rem;
}
</style>

View File

@@ -2,12 +2,12 @@
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, Monitor } from 'lucide-vue-next'; import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon, Sparkles, Shuffle, Grid3X3, Grid2X2, Grid, MousePointer2 } from 'lucide-vue-next';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n(); const { t, locale, setLocale, locales } = useI18n();
const emit = defineEmits(['open-custom', 'toggle-guide', 'set-theme']); const emit = defineEmits(['open-custom', 'open-image-import', 'toggle-guide', 'set-theme']);
const isGameOpen = ref(false); const isGameOpen = ref(false);
const isThemeOpen = ref(false); const isThemeOpen = ref(false);
@@ -16,6 +16,13 @@ const isMobileMenuOpen = ref(false);
const langMenuRef = ref(null); const langMenuRef = ref(null);
const searchTerm = ref(''); const searchTerm = ref('');
const getLevelIcon = (id) => {
if (id === 'easy') return Grid2X2;
if (id === 'medium') return Grid3X3;
if (id === 'hard') return Grid;
return Gamepad2;
};
// Map language codes to country codes for flag-icons // Map language codes to country codes for flag-icons
const langToCountry = { const langToCountry = {
en: 'gb', en: 'gb',
@@ -180,6 +187,12 @@ const openCustom = () => {
closeMobileMenu(); closeMobileMenu();
}; };
const openImageImport = () => {
emit('open-image-import');
isGameOpen.value = false;
closeMobileMenu();
};
const setTheme = (theme) => { const setTheme = (theme) => {
emit('set-theme', theme); emit('set-theme', theme);
isThemeOpen.value = false; isThemeOpen.value = false;
@@ -251,10 +264,16 @@ watch(isMobileMenuOpen, (val) => {
class="dropdown-item" class="dropdown-item"
@click="selectLevel(lvl.id)" @click="selectLevel(lvl.id)"
> >
<component :is="getLevelIcon(lvl.id)" :size="16" />
{{ lvl.label }} {{ lvl.label }}
</button> </button>
<button class="dropdown-item" @click="openCustom"> <button class="dropdown-item" @click="openCustom">
{{ t('level.custom') }} <Shuffle :size="16" />
{{ t('level.custom_random') }}
</button>
<button class="dropdown-item" @click="openImageImport">
<ImageIcon :size="16" />
{{ t('level.custom_image') }}
</button> </button>
</div> </div>
</transition> </transition>
@@ -321,8 +340,10 @@ watch(isMobileMenuOpen, (val) => {
</transition> </transition>
</div> </div>
</div> </div>
</nav>
<!-- Mobile Menu Overlay --> <!-- Mobile Menu Overlay -->
<Teleport to="body">
<transition name="fade"> <transition name="fade">
<div v-if="isMobileMenuOpen" class="mobile-menu-overlay"> <div v-if="isMobileMenuOpen" class="mobile-menu-overlay">
<div class="mobile-menu-header"> <div class="mobile-menu-header">
@@ -346,10 +367,16 @@ watch(isMobileMenuOpen, (val) => {
class="mobile-sub-item" class="mobile-sub-item"
@click="selectLevel(lvl.id)" @click="selectLevel(lvl.id)"
> >
<component :is="getLevelIcon(lvl.id)" :size="16" />
{{ lvl.label }} {{ lvl.label }}
</button> </button>
<button class="mobile-sub-item" @click="openCustom"> <button class="mobile-sub-item" @click="openCustom">
{{ t('level.custom') }} <Shuffle :size="16" />
{{ t('level.custom_random') }}
</button>
<button class="mobile-sub-item" @click="openImageImport">
<ImageIcon :size="16" />
{{ t('level.custom_image') }}
</button> </button>
</div> </div>
</div> </div>
@@ -402,7 +429,7 @@ watch(isMobileMenuOpen, (val) => {
/> />
</div> </div>
<div class="lang-list mobile-lang-list"> <div class="lang-list mobile-lang-list">
<button <button
v-for="lang in filteredLanguages" v-for="lang in filteredLanguages"
:key="lang.code" :key="lang.code"
class="mobile-sub-item" class="mobile-sub-item"
@@ -420,7 +447,7 @@ watch(isMobileMenuOpen, (val) => {
</div> </div>
</div> </div>
</transition> </transition>
</nav> </Teleport>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { generateRandomGrid, calculateHints } from '@/utils/puzzleUtils'; import { generateRandomGrid, calculateHints } from '@/utils/puzzleUtils';
import { solvePuzzle } from '@/utils/solver'; import { solvePuzzle } from '@/utils/solver';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
@@ -9,9 +9,9 @@ import { X, Play, Square, RotateCcw } from 'lucide-vue-next';
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50]; const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80];
const DENSITIES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; 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 SAMPLES_PER_POINT = 50; // Increased for better accuracy
const isRunning = ref(false); const isRunning = ref(false);
const progress = ref(0); const progress = ref(0);
@@ -21,6 +21,22 @@ const simulationSpeed = ref(1); // 1 = Normal, 2 = Fast (less render updates)
let stopRequested = false; let stopRequested = false;
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopImmediatePropagation?.();
e.preventDefault?.();
emit('close');
}
};
onMounted(() => {
window.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
});
const displayStatus = computed(() => { const displayStatus = computed(() => {
if (!currentStatus.value) return t('simulation.status.ready'); if (!currentStatus.value) return t('simulation.status.ready');
return currentStatus.value; return currentStatus.value;
@@ -55,11 +71,12 @@ const startSimulation = async () => {
for (let i = 0; i < SAMPLES_PER_POINT; i++) { for (let i = 0; i < SAMPLES_PER_POINT; i++) {
const grid = generateRandomGrid(size, density); const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid); const { rowHints, colHints } = calculateHints(grid);
const { percentSolved } = solvePuzzle(rowHints, colHints); // Use logicOnly=true for fast simulation
const { percentSolved } = solvePuzzle(rowHints, colHints, null, null, true);
totalSolved += percentSolved; totalSolved += percentSolved;
// Yield to UI every few samples to keep it responsive // Yield to UI every few samples to keep it responsive
if (i % 2 === 0) await new Promise(r => setTimeout(r, 0)); if (i % 10 === 0) await new Promise(r => setTimeout(r, 0));
} }
const avgSolved = totalSolved / SAMPLES_PER_POINT; const avgSolved = totalSolved / SAMPLES_PER_POINT;

View File

@@ -28,6 +28,8 @@ const handleClose = () => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.stopImmediatePropagation?.();
e.preventDefault?.();
handleClose(); handleClose();
} }
}; };
@@ -213,6 +215,8 @@ onUnmounted(() => {
width: fit-content; width: fit-content;
max-width: min(92vw, 560px); max-width: min(92vw, 560px);
min-width: 280px; min-width: 280px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--primary-accent); border: 1px solid var(--primary-accent);
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.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); animation: slideUp 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
@@ -221,6 +225,12 @@ onUnmounted(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@media (max-width: 768px), (max-height: 600px) {
.modal {
padding: 20px;
}
}
h2 { h2 {
font-size: 2.5rem; font-size: 2.5rem;
color: var(--primary-accent); color: var(--primary-accent);

File diff suppressed because it is too large Load Diff

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, 62]; const speeds = [1000, 500, 250, 125, 62, 31, 16];
const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16']; const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16', 'x32', 'x64'];
const statusText = ref(t('guide.waiting')); const statusText = ref(t('guide.waiting'));
let intervalId = null; let intervalId = null;

View File

@@ -32,12 +32,15 @@ export const usePuzzleStore = defineStore('puzzle', () => {
let count = 0; let count = 0;
if (solution.value.length === 0 || playerGrid.value.length === 0) return 0; if (solution.value.length === 0 || playerGrid.value.length === 0) return 0;
for (let r = 0; r < size.value; r++) { const rows = solution.value.length;
for (let c = 0; c < size.value; c++) { const cols = solution.value[0].length;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
// Zliczamy tylko poprawne wypełnienia (czarne), // Zliczamy tylko poprawne wypełnienia (czarne),
// ale w nonogramach postęp to często: (poprawne_czarne - bledne_czarne) / total_czarne // ale w nonogramach postęp to często: (poprawne_czarne - bledne_czarne) / total_czarne
// Zróbmy prostą wersję: % poprawnie zaznaczonych czarnych - błędnie zaznaczone czarne // Zróbmy prostą wersję: % poprawnie zaznaczonych czarnych - błędnie zaznaczone czarne
if (playerGrid.value[r][c] === 1) { if (playerGrid.value[r] && playerGrid.value[r][c] === 1) {
if (solution.value[r][c] === 1) count++; if (solution.value[r][c] === 1) count++;
else count--; // kara za błąd else count--; // kara za błąd
} }
@@ -93,10 +96,36 @@ export const usePuzzleStore = defineStore('puzzle', () => {
saveState(); saveState();
} }
function initFromImage(grid) {
stopTimer();
currentLevelId.value = 'custom_image';
// Use the larger dimension for size to ensure loops cover everything if square-assumption exists
// But ideally we should support rectangular.
// For now, size.value is used in resetGrid loop.
// Let's update resetGrid to handle rectangular.
size.value = Math.max(grid.length, grid[0].length);
solution.value = grid;
resetGrid();
isGameWon.value = false;
hasUsedGuide.value = false;
guideUsageCount.value = 0;
// Calculate density
const totalFilled = grid.flat().filter(c => c === 1).length;
currentDensity.value = totalFilled / (size.value * size.value);
elapsedTime.value = 0;
startTimer();
saveState();
}
function resetGrid() { function resetGrid() {
playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0)); const rows = solution.value.length;
moves.value = 0; const cols = solution.value[0].length;
playerGrid.value = Array(rows).fill().map(() => Array(cols).fill(0));
history.value = []; history.value = [];
moves.value = 0;
currentTransaction.value = null; currentTransaction.value = null;
} }
@@ -198,11 +227,14 @@ export const usePuzzleStore = defineStore('puzzle', () => {
// Calculate expected hints from solution (truth) // Calculate expected hints from solution (truth)
// We do this dynamically to ensure we always check against the rules of the board // We do this dynamically to ensure we always check against the rules of the board
const rows = solution.value.length;
const cols = solution.value[0].length;
const solutionRows = solution.value; const solutionRows = solution.value;
const solutionCols = Array(size.value).fill().map((_, c) => solution.value.map(r => r[c])); const solutionCols = Array(cols).fill().map((_, c) => solution.value.map(r => r[c]));
// Check Rows // Check Rows
for (let r = 0; r < size.value; r++) { for (let r = 0; r < rows; r++) {
const targetHints = calculateLineHints(solutionRows[r]); const targetHints = calculateLineHints(solutionRows[r]);
const playerLine = playerGrid.value[r]; const playerLine = playerGrid.value[r];
if (!validateLine(playerLine, targetHints)) { if (!validateLine(playerLine, targetHints)) {
@@ -213,7 +245,7 @@ export const usePuzzleStore = defineStore('puzzle', () => {
if (correct) { if (correct) {
// Check Columns // Check Columns
for (let c = 0; c < size.value; c++) { for (let c = 0; c < cols; c++) {
const targetHints = calculateLineHints(solutionCols[c]); const targetHints = calculateLineHints(solutionCols[c]);
const playerLine = playerGrid.value.map(row => row[c]); const playerLine = playerGrid.value.map(row => row[c]);
if (!validateLine(playerLine, targetHints)) { if (!validateLine(playerLine, targetHints)) {
@@ -332,6 +364,7 @@ export const usePuzzleStore = defineStore('puzzle', () => {
progressPercentage, progressPercentage,
initGame, initGame,
initCustomGame, initCustomGame,
initFromImage,
toggleCell, toggleCell,
setCell, setCell,
resetGame, resetGame,

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { solvePuzzle } from './solver';
import { calculateHints } from './puzzleUtils';
describe('Debug Solver', () => {
it('should solve the broken grid', () => {
const grid = [
[0,1,1,1,0,0,1,0,1,1],
[1,1,1,0,0,1,1,1,0,0],
[1,0,1,0,1,0,0,1,0,0],
[1,0,0,0,1,1,1,1,0,1],
[1,1,0,1,0,0,0,1,0,1],
[1,0,1,0,1,0,0,0,1,0],
[1,1,1,0,0,1,1,0,0,0],
[0,1,0,0,1,0,1,0,0,0],
[0,0,0,1,1,0,0,0,1,0],
[1,0,1,1,0,0,1,0,1,1]
];
const { rowHints, colHints } = calculateHints(grid);
const result = solvePuzzle(rowHints, colHints);
console.log('Solve Result:', result);
expect(result.percentSolved).toBe(100);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { solvePuzzle } from './solver.js';
describe('Large Grid Solver', () => {
it('should solve a large 55x28 grid without crashing', () => {
const rows = 28;
const cols = 55;
// Create a simple pattern: checkerboard or lines
const grid = Array(rows).fill().map((_, r) =>
Array(cols).fill().map((_, c) => (r + c) % 2 === 0 ? 1 : 0)
);
// Calculate hints
const rowHints = grid.map(row => {
const hints = [];
let current = 0;
row.forEach(cell => {
if (cell === 1) current++;
else if (current > 0) { hints.push(current); current = 0; }
});
if (current > 0) hints.push(current);
return hints.length ? hints : [0];
});
const colHints = Array(cols).fill().map((_, c) => {
const hints = [];
let current = 0;
for(let r=0; r<rows; r++) {
if (grid[r][c] === 1) current++;
else if (current > 0) { hints.push(current); current = 0; }
}
if (current > 0) hints.push(current);
return hints.length ? hints : [0];
});
console.log('Starting solve...');
const result = solvePuzzle(rowHints, colHints, (p) => console.log(`Progress: ${p}%`));
console.log('Result:', result);
expect(result.percentSolved).toBeGreaterThan(0);
expect(result.difficulty).toBeDefined();
});
});

View File

@@ -27,19 +27,20 @@ export function validateLine(line, targetHints) {
export function calculateHints(grid) { export function calculateHints(grid) {
if (!grid || grid.length === 0) return { rowHints: [], colHints: [] }; if (!grid || grid.length === 0) return { rowHints: [], colHints: [] };
const size = grid.length; const rows = grid.length;
const cols = grid[0].length;
const rowHints = []; const rowHints = [];
const colHints = []; const colHints = [];
// Row Hints // Row Hints
for (let r = 0; r < size; r++) { for (let r = 0; r < rows; r++) {
rowHints.push(calculateLineHints(grid[r])); rowHints.push(calculateLineHints(grid[r]));
} }
// Col Hints // Col Hints
for (let c = 0; c < size; c++) { for (let c = 0; c < cols; c++) {
const col = []; const col = [];
for (let r = 0; r < size; r++) { for (let r = 0; r < rows; r++) {
col.push(grid[r][c]); col.push(grid[r][c]);
} }
colHints.push(calculateLineHints(col)); colHints.push(calculateLineHints(col));
@@ -63,21 +64,23 @@ export function generateRandomGrid(size, density = 0.5) {
export function calculateDifficulty(density, size = 10) { export function calculateDifficulty(density, size = 10) {
// Data derived from Monte Carlo Simulation (Logical Solver) // Data derived from Monte Carlo Simulation (Logical Solver)
// Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] } // 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 = { const SIM_DATA = {
5: [89, 74, 74, 81, 97, 98, 99, 100, 100], 5: [88, 76, 71, 80, 90, 98, 99, 100, 100],
10: [57, 20, 16, 54, 92, 100, 100, 100, 100], 10: [58, 25, 18, 44, 81, 99, 100, 100, 100],
15: [37, 10, 2, 12, 68, 100, 100, 100, 100], 15: [36, 7, 3, 11, 67, 99, 100, 100, 100],
20: [23, 3, 1, 2, 37, 100, 100, 100, 100], 20: [24, 3, 0, 3, 48, 99, 100, 100, 100],
25: [16, 0, 0, 1, 19, 99, 100, 100, 100], 25: [13, 1, 0, 1, 21, 99, 100, 100, 100],
30: [8, 0, 0, 0, 5, 99, 100, 100, 100], 30: [9, 0, 0, 0, 7, 99, 100, 100, 100],
35: [6, 0, 0, 0, 4, 91, 100, 100, 100], 35: [5, 0, 0, 0, 5, 97, 100, 100, 100],
40: [3, 0, 0, 0, 2, 91, 100, 100, 100], 40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
45: [2, 0, 0, 0, 1, 82, 100, 100, 100], 45: [2, 0, 0, 0, 1, 84, 100, 100, 100],
50: [2, 0, 0, 0, 1, 73, 100, 100, 100], 50: [1, 0, 0, 0, 0, 65, 100, 100, 100],
55: [1, 0, 0, 0, 0, 55, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100], 60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
71: [0, 0, 0, 0, 0, 16, 100, 100, 100], 65: [0, 0, 0, 0, 0, 20, 100, 100, 100],
80: [0, 0, 0, 0, 0, 1, 100, 100, 100] 70: [0, 0, 0, 0, 0, 11, 100, 100, 100],
75: [0, 0, 0, 0, 0, 12, 100, 100, 100],
80: [0, 0, 0, 0, 0, 4, 100, 100, 100]
}; };
// Helper to get interpolated value from array // Helper to get interpolated value from array
@@ -122,17 +125,41 @@ export function calculateDifficulty(density, size = 10) {
const solvedPct = getSimulatedSolvedPct(size, density); const solvedPct = getSimulatedSolvedPct(size, density);
// Difficulty Score: Inverse of Solved Percent let value;
// 100% Solved -> 0 Difficulty let level;
// 0% Solved -> 100 Difficulty
const value = Math.round(100 - solvedPct);
// Thresholds // "Hardest" threshold is 99% solvability.
let level = 'easy'; // We calculate a base value first, then adjust for solvability.
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 }; const densityFactor = 1 - 2 * Math.abs(density - 0.5);
const complexity = size * (0.4 + 0.6 * densityFactor);
if (solvedPct < 99) {
// Requires guessing / advanced logic.
// Base penalty for low solvability: 85 to 100
const penaltyBase = 85 + ((99 - solvedPct) / 99) * 15;
// Scale penalty by size.
// Small grids (e.g. 5x5) are trivial even if "unsolvable" by simple logic.
// Large grids (e.g. 20x20) are truly extreme if unsolvable.
const sizeFactor = Math.min(1, size / 20);
value = penaltyBase * sizeFactor;
// Ensure difficulty doesn't drop below structural complexity
value = Math.max(value, complexity);
} else {
// Solvable (>= 99%)
// Complexity based on Size and Density
// Max size 80.
// Formula: size * (0.4 + 0.6 * densityFactor)
value = Math.min(85, complexity);
}
if (value < 25) level = 'easy';
else if (value < 55) level = 'harder';
else if (value < 85) level = 'hardest';
else level = 'extreme';
return { level, value: Math.round(value) };
} }

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { solvePuzzle } from './solver';
import { calculateHints } from './puzzleUtils';
describe('Solver Repro', () => {
it('should solve a simple generated puzzle', () => {
const grid = [
[1, 0, 1, 1, 0],
[1, 1, 0, 0, 1],
[0, 0, 1, 0, 0],
[1, 1, 1, 1, 1],
[0, 1, 0, 1, 0]
];
const { rowHints, colHints } = calculateHints(grid);
const result = solvePuzzle(rowHints, colHints);
expect(result.percentSolved).toBe(100);
});
it('should not fail on random valid lines', () => {
// Test solveLine indirectly via solvePuzzle on small grids
for (let i = 0; i < 100; i++) {
const size = 10;
const grid = [];
for(let r=0; r<size; r++) {
const row = [];
for(let c=0; c<size; c++) row.push(Math.random() > 0.5 ? 1 : 0);
grid.push(row);
}
const { rowHints, colHints } = calculateHints(grid);
const result = solvePuzzle(rowHints, colHints);
// It might not be 100% solvable without guessing (logic only),
// but since our solver HAS backtracking, it MUST be 100% solvable
// (unless timeout/max depth reached, but for 10x10 it should solve).
// If it returns 0% or low %, it implies it failed to find the solution
// or found a contradiction (which shouldn't happen for valid hints).
if (result.percentSolved < 100) {
console.log('Failed Grid:', JSON.stringify(grid));
console.log('Result:', result);
}
expect(result.percentSolved).toBe(100);
}
});
});

View File

@@ -4,7 +4,7 @@ export function buildShareCanvas(data, t, formattedTime) {
const { grid, size, currentDensity, guideUsageCount } = data; const { grid, size, currentDensity, guideUsageCount } = data;
if (!grid || !grid.length) return null; if (!grid || !grid.length) return null;
const appUrl = 'https://nonograms.7u.pl/'; const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
const maxBoard = 640; const maxBoard = 640;
const cellSize = Math.max(8, Math.floor(maxBoard / size)); const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size; const boardSize = cellSize * size;
@@ -119,7 +119,7 @@ export function buildShareSVG(data, t, formattedTime) {
const { grid, size, currentDensity, guideUsageCount } = data; const { grid, size, currentDensity, guideUsageCount } = data;
if (!grid || !grid.length) return null; if (!grid || !grid.length) return null;
const appUrl = 'https://nonograms.7u.pl/'; const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
const maxBoard = 640; const maxBoard = 640;
const cellSize = Math.max(8, Math.floor(maxBoard / size)); const cellSize = Math.max(8, Math.floor(maxBoard / size));
const boardSize = cellSize * size; const boardSize = cellSize * size;
@@ -227,7 +227,11 @@ export function buildShareSVG(data, t, formattedTime) {
} }
// URL // 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 += `
<a href="${appUrl}" target="_blank">
<text x="${padding}" y="${height - padding + 6}" font-family="Segoe UI, sans-serif" font-weight="500" font-size="14" fill="${urlColor}" style="text-decoration: underline; cursor: pointer;">${appUrl}</text>
</a>
`;
svgContent += '</svg>'; svgContent += '</svg>';
return svgContent; return svgContent;

View File

@@ -6,6 +6,9 @@
* 1: Filled * 1: Filled
*/ */
// Memoized helper for checking if hints fit
const memo = new Map();
/** /**
* Solves a single line (row or column) based on hints and current knowledge. * Solves a single line (row or column) based on hints and current knowledge.
* Uses the "Left-Right Overlap" algorithm to find common filled cells. * Uses the "Left-Right Overlap" algorithm to find common filled cells.
@@ -15,10 +18,13 @@
* @param {number[]} hints - Array of block lengths * @param {number[]} hints - Array of block lengths
* @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles) * @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles)
*/ */
function solveLine(currentLine, hints) { export function solveLine(currentLine, hints) {
const length = currentLine.length; const length = currentLine.length;
// If no hints, all must be empty // If no hints, all must be empty
// Clear memo for this line solve
memo.clear();
if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) { if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) {
return Array(length).fill(0); return Array(length).fill(0);
} }
@@ -45,28 +51,23 @@ function solveLine(currentLine, hints) {
while (currentIdx <= length - block) { while (currentIdx <= length - block) {
if (canPlace(currentLine, currentIdx, block)) { if (canPlace(currentLine, currentIdx, block)) {
// Verify we can fit remaining blocks // 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)) { if (canFitRest(currentLine, currentIdx + block + 1, hints, hIndex + 1)) {
leftPositions.push(currentIdx); leftPositions.push(currentIdx);
currentIdx += block + 1; // Move past this block + 1 space currentIdx += block + 1; // Move past this block + 1 space
break; break;
} }
} }
// Cannot skip a filled cell - if we pass a '1', it becomes uncovered
if (currentLine[currentIdx] === 1) return null;
currentIdx++; currentIdx++;
} }
if (leftPositions.length <= hIndex) return null; // Impossible if (leftPositions.length <= hIndex) return null; // Impossible
} }
// 2. Calculate Right-Most Positions (by reversing line and hints) // Clear memo for right-side calculation (different line/hints)
// This is symmetrical to Left-Most. memo.clear();
// 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. // 2. Calculate Right-Most Positions (by reversing line and hints)
const reversedLine = [...currentLine].reverse(); const reversedLine = [...currentLine].reverse();
const reversedHints = [...hints].reverse(); const reversedHints = [...hints].reverse();
const rightPositionsReversed = []; const rightPositionsReversed = [];
@@ -82,14 +83,14 @@ function solveLine(currentLine, hints) {
break; break;
} }
} }
// Cannot skip a filled cell
if (reversedLine[currentIdx] === 1) return null;
currentIdx++; currentIdx++;
} }
if (rightPositionsReversed.length <= hIndex) return null; if (rightPositionsReversed.length <= hIndex) return null;
} }
// Convert reversed positions to actual indices // 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 rightPositions = rightPositionsReversed.map((rStart, i) => {
const block = reversedHints[i]; const block = reversedHints[i];
return length - rStart - block; return length - rStart - block;
@@ -106,13 +107,6 @@ function solveLine(currentLine, hints) {
const block = hints[i]; const block = hints[i];
// If overlap exists: [r, l + block - 1] // 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) { if (r < l + block) {
for (let k = r; k < l + block; k++) { for (let k = r; k < l + block; k++) {
newLine[k] = 1; newLine[k] = 1;
@@ -121,15 +115,6 @@ function solveLine(currentLine, hints) {
} }
// Determine Empty cells? // 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 // Mask of possible filled cells
const possibleFilled = Array(length).fill(false); const possibleFilled = Array(length).fill(false);
for (let i = 0; i < hints.length; i++) { for (let i = 0; i < hints.length; i++) {
@@ -148,8 +133,7 @@ function solveLine(currentLine, hints) {
} }
// Memoized helper for checking if hints fit // Memoized helper for checking if hints fit
const memo = new Map(); export function canFitRest(line, startIndex, hints, hintIndex) {
function canFitRest(line, startIndex, hints, hintIndex) {
// Optimization: If hints are empty, we just need to check if remaining line has no '1's // Optimization: If hints are empty, we just need to check if remaining line has no '1's
if (hintIndex >= hints.length) { if (hintIndex >= hints.length) {
for (let i = startIndex; i < line.length; i++) { for (let i = startIndex; i < line.length; i++) {
@@ -158,23 +142,32 @@ function canFitRest(line, startIndex, hints, hintIndex) {
return true; return true;
} }
// Key for memoization (primitive approach) // Memoization key
// In a full solver, we'd pass a cache. For single line, maybe overkill, but safe. const key = `${startIndex}-${hintIndex}`;
// let key = `${startIndex}-${hintIndex}`; if (memo.has(key)) return memo.get(key);
// Skipping memo for now as line lengths are small (<80) and recursion depth is low.
const remainingLen = line.length - startIndex; const remainingLen = line.length - startIndex;
// Min space needed: sum of hints + (hints.length - 1) spaces // Min space needed: sum of hints + (hints.length - 1) spaces
// Calculate lazily or precalc?
let minSpace = 0; let minSpace = 0;
for(let i=hintIndex; i<hints.length; i++) minSpace += hints[i] + (i < hints.length - 1 ? 1 : 0); for(let i=hintIndex; i<hints.length; i++) minSpace += hints[i] + (i < hints.length - 1 ? 1 : 0);
if (remainingLen < minSpace) return false; if (remainingLen < minSpace) {
memo.set(key, false);
return false;
}
const block = hints[hintIndex]; const block = hints[hintIndex];
// Try to find *any* valid placement for this block // 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++) {
for (let i = startIndex; i <= line.length - minSpace; i++) { // Optimization on upper bound? // If we skipped a '1', we went too far. All 1s must be covered by blocks.
// Since we are placing the *next* block, any 1s between startIndex and i are uncovered.
// Thus, if we find a 1 in [startIndex, i-1], we must stop.
let skippedOne = false;
for (let x = startIndex; x < i; x++) {
if (line[x] === 1) { skippedOne = true; break; }
}
if (skippedOne) break;
// Check placement // Check placement
let valid = true; let valid = true;
// Block // Block
@@ -183,96 +176,276 @@ function canFitRest(line, startIndex, hints, hintIndex) {
} }
if (!valid) continue; if (!valid) continue;
// Boundary before (checked by loop start usually, but strictly: // Boundary before
if (i > 0 && line[i-1] === 1) valid = false; // Should have been handled by caller or skip if (i > 0 && line[i-1] === 1) valid = false;
// 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: // Boundary after (check implicit in next block placement or end of line, but we need to check i+block cell)
// 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 (i + block < line.length && line[i+block] === 1) valid = false;
if (valid) { if (valid) {
// Recurse // Recurse
if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) return true; if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) {
memo.set(key, true);
return true;
}
} }
} }
memo.set(key, false);
return false; return false;
} }
/** /**
* Solves the puzzle using logical iteration. * Main solver function that attempts to solve the puzzle using logic and backtracking (DFS).
* Uses an efficient propagation queue and undo stack to avoid deep copying the grid.
*
* @param {number[][]} rowHints * @param {number[][]} rowHints
* @param {number[][]} colHints * @param {number[][]} colHints
* @returns {object} { solvedGrid: number[][], percentSolved: number } * @param {function} onProgress - Optional callback for progress reporting (percent)
* @param {number[][]} initialGrid - Optional initial grid state
* @param {boolean} logicOnly - If true, stops after logical propagation (no guessing)
* @returns {object} result
*/ */
export function solvePuzzle(rowHints, colHints) { export function solvePuzzle(rowHints, colHints, onProgress, initialGrid = null, logicOnly = false) {
const rows = rowHints.length; const rows = rowHints.length;
const cols = colHints.length; const cols = colHints.length;
const totalCells = rows * cols;
// Initialize grid with -1 // Grid initialization: -1 (Unknown), 0 (Empty), 1 (Filled)
let grid = Array(rows).fill(null).map(() => Array(cols).fill(-1)); // Use initialGrid if provided (deep copy to be safe)
const grid = initialGrid
? initialGrid.map(row => [...row])
: Array(rows).fill().map(() => Array(cols).fill(-1));
let changed = true; // Stats
let iterations = 0; let iterations = 0; // Total calls to solve()
const MAX_ITER = 100; // Safety break let maxDepth = 0; // Max recursion depth
let backtracks = 0; // Failed guesses
let logicSteps = 0; // Cells filled by propagation
let lastProgressTime = 0;
while (changed && iterations < MAX_ITER) { function reportProgress() {
changed = false; if (!onProgress) return;
iterations++; const now = Date.now();
if (now - lastProgressTime >= 50) {
// Rows let filled = 0;
for (let r = 0; r < rows; r++) { for(let r=0; r<rows; r++) {
const newLine = solveLine(grid[r], rowHints[r]); for(let c=0; c<cols; c++) {
if (newLine) { if(grid[r][c] !== -1) filled++;
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;
}
} }
} }
onProgress(Math.floor((filled/totalCells) * 100));
lastProgressTime = now;
} }
} }
// Calculate solved % // Queue for propagation (Set of strings "r{i}" or "c{i}")
const queue = new Set();
for(let r=0; r<rows; r++) queue.add(`r${r}`);
for(let c=0; c<cols; c++) queue.add(`c${c}`);
// Helper: Undo changes from a propagation step
function undo(changes) {
for(let i=changes.length-1; i>=0; i--) {
const {r, c, old} = changes[i];
grid[r][c] = old;
}
}
// Helper: Propagate logic constraints until fixed point or contradiction
// Returns list of changes made, or null if contradiction found
function propagate() {
const changes = [];
try {
while(queue.size > 0) {
reportProgress();
const item = queue.values().next().value;
queue.delete(item);
const type = item[0];
const idx = parseInt(item.slice(1));
let currentLine, hints;
if (type === 'r') {
currentLine = grid[idx]; // Reference for row (fast)
hints = rowHints[idx];
} else {
currentLine = grid.map(row => row[idx]); // Copy for col (slower)
hints = colHints[idx];
}
const newLine = solveLine(currentLine, hints);
if (!newLine) throw 'contradiction';
// Apply changes
for(let i=0; i<newLine.length; i++) {
if (currentLine[i] !== newLine[i]) {
// If we try to change a known value to something else -> Contradiction
if (currentLine[i] !== -1 && currentLine[i] !== newLine[i]) throw 'contradiction';
if (currentLine[i] === -1) {
const r = type === 'r' ? idx : i;
const c = type === 'r' ? i : idx;
// Double check if already set by orthogonal update in same loop?
// (Should be handled by -1 check above, as grid is shared)
if (grid[r][c] === -1) {
grid[r][c] = newLine[i];
changes.push({r, c, old: -1});
logicSteps++;
// Add orthogonal line to queue
if (type === 'r') queue.add(`c${c}`);
else queue.add(`r${r}`);
} else if (grid[r][c] !== newLine[i]) {
console.log('Contradiction: Orthogonal Conflict at', r, c, 'Grid:', grid[r][c], 'New:', newLine[i]);
throw 'contradiction';
}
}
}
}
}
} catch (e) {
// Revert changes from this failed propagation
undo(changes);
return null;
}
return changes;
}
// DFS Solver
function solve(depth) {
maxDepth = Math.max(maxDepth, depth);
iterations++;
reportProgress();
// 1. Propagate Logic
const changes = propagate();
if (!changes) return false; // Contradiction
// 2. Find Best Branch Candidate
let bestR = -1, bestC = -1;
let minUnknowns = Infinity;
let isComplete = true;
// Scan for unknowns and pick heuristic
// Heuristic: Line with fewest unknowns (most constrained)
for(let r=0; r<rows; r++) {
let unknowns = 0;
let firstUnknownC = -1;
for(let c=0; c<cols; c++) {
if(grid[r][c] === -1) {
unknowns++;
if (firstUnknownC === -1) firstUnknownC = c;
}
}
if (unknowns > 0) {
isComplete = false;
if (unknowns < minUnknowns) {
minUnknowns = unknowns;
bestR = r;
bestC = firstUnknownC;
}
if (minUnknowns === 1) break; // Optimal
}
}
if (isComplete) return true; // Solved!
// 3. Branching (Guessing)
// Try 1
grid[bestR][bestC] = 1;
queue.add(`r${bestR}`);
queue.add(`c${bestC}`);
if (solve(depth + 1)) return true;
// Backtrack from 1
grid[bestR][bestC] = -1; // Undo guess
// (Recursive call already undid its propagation changes)
// Try 0
grid[bestR][bestC] = 0;
queue.add(`r${bestR}`);
queue.add(`c${bestC}`);
if (solve(depth + 1)) return true;
// Backtrack from 0
grid[bestR][bestC] = -1; // Undo guess
backtracks++;
// Undo propagation from this level
undo(changes);
return false;
}
// Start Solving
if (logicOnly) {
// Just logical propagation without guessing
if (initialGrid) {
// If resuming, ensure queue has all lines to check consistency
queue.clear();
for(let r=0; r<rows; r++) queue.add(`r${r}`);
for(let c=0; c<cols; c++) queue.add(`c${c}`);
}
// Propagate logic constraints
propagate();
// No DFS, so iterations/backtracks remain 0
} else if (!initialGrid) {
// Normal start (full solver)
solve(0);
} else {
// Resume from provided state (full solver)
// We need to populate the queue with all rows/cols since we don't know what changed
queue.clear();
for(let r=0; r<rows; r++) queue.add(`r${r}`);
for(let c=0; c<cols; c++) queue.add(`c${c}`);
solve(0);
}
// Calculate Percent Solved
let solvedCount = 0; let solvedCount = 0;
for (let r = 0; r < rows; r++) { grid.forEach(r => r.forEach(c => { if(c !== -1) solvedCount++; }));
for (let c = 0; c < cols; c++) { let percentSolved = (solvedCount / totalCells) * 100;
if (grid[r][c] !== -1) solvedCount++;
if (onProgress) onProgress(Math.floor(percentSolved));
// Difficulty Calculation
// Logic:
// - Base: 0-20% based on size/density
// - Logic: 0-30% based on iterations needed (depth 0)
// - Guessing: 0-50% based on backtracks/depth
let difficultyScore = 0;
const effectiveSize = Math.sqrt(totalCells);
if (percentSolved < 100) {
difficultyScore = 100; // Unsolvable (or timed out/too hard)
} else {
if (maxDepth === 0) {
// Pure logic
difficultyScore = Math.min(30, effectiveSize);
} else {
// Required guessing
// Simple heuristic: 30 + backtracks * 5 + depth * 2
difficultyScore = 30 + (backtracks * 2) + (maxDepth * 5);
difficultyScore = Math.min(100, difficultyScore);
} }
} }
return { return {
solvedGrid: grid, percentSolved,
percentSolved: (solvedCount / (rows * cols)) * 100 difficultyScore: Math.round(difficultyScore),
lookaheadUsed: maxDepth > 0,
iterations,
maxDepth,
backtracks
}; };
} }

64
src/utils/solver.test.js Normal file
View File

@@ -0,0 +1,64 @@
it('solves a puzzle requiring guessing (Backtracking)', () => {
// A puzzle that logic alone cannot start usually has multiple solutions or requires a guess.
// Example: The "domino" or "ambiguous" pattern, but we need a unique solution that requires lookahead.
// Or just a very hard unique puzzle.
// A simple case where line logic is stuck but global constraints solve it.
//
// 0 1 1 0
// 1 0 0 1
// 1 0 0 1
// 0 1 1 0
// Hints:
// R: 2, 1 1, 1 1, 2
// C: 2, 1 1, 1 1, 2
// This is a "ring". It might be solvable by logic if corners are forced.
// Let's try a known "hard" small pattern.
//
// 0 0 0
// 0 1 0
// 0 0 0
// R: 0, 1, 0
// C: 0, 1, 0
// Logic solves this instantly.
// Let's trust the logic works for general backtracking by forcing a guess.
// We can mock solveLine to return "no change" to force backtracking? No, integration test is better.
// Let's just ensure it returns a valid result structure for a solvable puzzle.
const rowHints = [[1], [1], [1]];
const colHints = [[1], [1], [1]];
// 3x3 diagonal?
// 1 0 0
// 0 1 0
// 0 0 1
// Hints: 1, 1, 1
// Cols: 1, 1, 1
// This has multiple solutions (diagonal or anti-diagonal or others).
// Our solver should find ONE of them and return 100%.
const result = solvePuzzle(rowHints, colHints);
expect(result.percentSolved).toBe(100);
// It might use lookahead because logic can't decide.
// Actually for this specific case, logic does nothing (all empty or all full are not possible, but many perms).
// So it MUST branch.
expect(result.maxDepth).toBeGreaterThan(0);
});
it('stress test: should solve 100 random valid 10x10 grids', () => {
// This ensures the solver is robust and doesn't fail on valid puzzles.
// Using a fixed seed or just running a loop.
for (let i = 0; i < 100; i++) {
const size = 10;
const grid = generateRandomGrid(size, 0.5);
const { rowHints, colHints } = calculateHints(grid);
const result = solvePuzzle(rowHints, colHints);
if (result.percentSolved < 100) {
console.error('Failed Grid:', JSON.stringify(grid));
console.error('Result:', result);
}
expect(result.percentSolved).toBe(100);
}
});
});

170
src/utils/workerPool.js Normal file
View File

@@ -0,0 +1,170 @@
import SolverWorker from '../workers/solver.worker.js?worker';
class WorkerPool {
constructor() {
this.workers = [];
this.queue = [];
this.active = 0;
this.poolSize = navigator.hardwareConcurrency || 4;
for (let i = 0; i < this.poolSize; i++) {
const worker = new SolverWorker();
worker.onmessage = (e) => this.handleWorkerMessage(worker, e);
worker.onerror = (e) => this.handleWorkerError(worker, e);
this.workers.push({ worker, busy: false, id: i });
}
}
run(taskData, onProgress) {
return new Promise((resolve, reject) => {
const task = { data: taskData, resolve, reject, onProgress };
const freeWorker = this.workers.find(w => !w.busy);
if (freeWorker) {
this.execute(freeWorker, task);
} else {
this.queue.push(task);
}
});
}
runRace(tasks) {
return new Promise((resolve, reject) => {
let activeCount = tasks.length;
let resolved = false;
tasks.forEach(taskData => {
this.run(taskData.data, taskData.onProgress)
.then(result => {
if (resolved) return;
// Heuristic: If solved 100%, we have a winner
if (result.solvability === 100) {
resolved = true;
resolve(result);
// Cancel others (optional but good for perf)
// We can't easily cancel *specific* other tasks in this pool implementation without IDs
// But since this is a "Race", we assume the caller will handle cleanup or we just let them finish
} else {
// If not fully solved, we wait for others?
// Or maybe we collect all results and pick best?
// For "Race", we usually want the first *Success*.
// If all fail (finish without 100%), we reject or return best.
activeCount--;
if (activeCount === 0) {
// All finished, none 100%. Return the last one (or logic to pick best)
resolve(result);
}
}
})
.catch(err => {
if (resolved) return;
activeCount--;
if (activeCount === 0) {
reject(new Error('All workers failed'));
}
});
});
});
}
execute(workerObj, task) {
workerObj.busy = true;
workerObj.currentTask = task;
this.active++;
workerObj.worker.postMessage(task.data);
}
handleWorkerMessage(worker, e) {
const workerObj = this.workers.find(w => w.worker === worker);
if (workerObj && workerObj.currentTask) {
if (e.data.type === 'progress') {
if (workerObj.currentTask.onProgress) {
workerObj.currentTask.onProgress(e.data.percent);
}
return; // Don't resolve yet
}
if (e.data.error) {
workerObj.currentTask.reject(new Error(e.data.error));
} else {
workerObj.currentTask.resolve(e.data);
}
workerObj.currentTask = null;
workerObj.busy = false;
this.active--;
this.processQueue();
}
}
handleWorkerError(worker, e) {
const workerObj = this.workers.find(w => w.worker === worker);
if (workerObj && workerObj.currentTask) {
workerObj.currentTask.reject(e);
workerObj.currentTask = null;
workerObj.busy = false;
this.active--;
this.processQueue();
}
}
processQueue() {
if (this.queue.length > 0) {
const freeWorker = this.workers.find(w => !w.busy);
if (freeWorker) {
const task = this.queue.shift();
this.execute(freeWorker, task);
}
}
}
clearQueue() {
this.queue.forEach(task => {
task.reject(new Error('Cancelled'));
});
this.queue = [];
}
cancelAll() {
this.clearQueue();
// Terminate and restart busy workers
this.workers.forEach((w, index) => {
if (w.busy) {
w.worker.terminate();
if (w.currentTask) {
w.currentTask.reject(new Error('Terminated'));
}
// Create replacement
const newWorker = new SolverWorker();
newWorker.onmessage = (e) => this.handleWorkerMessage(newWorker, e);
newWorker.onerror = (e) => this.handleWorkerError(newWorker, e);
// Replace in array
this.workers[index] = { worker: newWorker, busy: false, id: w.id };
}
});
// Reset active count since all busy workers were replaced with idle ones
this.active = 0;
}
terminate() {
this.workers.forEach(w => w.worker.terminate());
this.workers = [];
this.queue = [];
}
}
// Singleton instance
let poolInstance = null;
export const getWorkerPool = () => {
if (!poolInstance) {
poolInstance = new WorkerPool();
}
return poolInstance;
};

View File

@@ -0,0 +1,62 @@
import { calculateHints } from '../utils/puzzleUtils';
import { solvePuzzle } from '../utils/solver';
self.onmessage = (e) => {
const { id, grid, initialGrid } = e.data;
try {
if (!grid || grid.length === 0) {
self.postMessage({ id, error: 'Empty grid' });
return;
}
const rows = grid.length;
const cols = grid[0].length;
// Use initialGrid if provided, otherwise assume we are starting fresh
// BUT wait, 'grid' passed here is usually the 0/1 grid from Image Import (target pattern).
// 'initialGrid' would be the partial solution state (-1/0/1).
// 1. Calculate Hints from the TARGET grid (the image)
const { rowHints, colHints } = calculateHints(grid);
// 2. Run Solver (Logic + Lookahead)
const onProgress = (percent) => {
self.postMessage({
id,
type: 'progress',
percent
});
};
const { percentSolved, difficultyScore, lookaheadUsed } = solvePuzzle(rowHints, colHints, onProgress, initialGrid);
// 3. Determine Level
let value = difficultyScore;
let level;
if (percentSolved < 100) {
level = 'extreme'; // Unsolvable by logic+lookahead
} else {
if (value < 25) level = 'easy';
else if (value < 50) level = 'medium';
else if (value < 75) level = 'hard';
else level = 'extreme';
}
// Add specific note if lookahead was needed?
// UI doesn't have a field for that, but we can encode it in difficultyLabel if needed.
// For now, standard levels are fine.
self.postMessage({
id,
solvability: Math.floor(percentSolved),
difficulty: Math.round(value),
difficultyLabel: level,
rows,
cols
});
} catch (err) {
self.postMessage({ id, error: err.message });
}
};

View File

@@ -1,4 +1,5 @@
import { calculateHints } from '../utils/puzzleUtils.js'; import { calculateHints } from '../utils/puzzleUtils.js';
import { solveLine } from '../utils/solver.js';
const messages = { const messages = {
pl: { pl: {
@@ -40,195 +41,33 @@ const t = (locale, key, params) => {
return typeof value === 'string' ? format(value, params) : key; return typeof value === 'string' ? format(value, params) : key;
}; };
const buildPrefix = (lineState) => {
const n = lineState.length;
const filled = new Array(n + 1).fill(0);
const cross = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) {
filled[i + 1] = filled[i] + (lineState[i] === 1 ? 1 : 0);
cross[i + 1] = cross[i] + (lineState[i] === 2 ? 1 : 0);
}
return { filled, cross };
};
const buildSuffixMin = (hints) => {
const m = hints.length;
const suffixMin = new Array(m + 1).fill(0);
let sumHints = 0;
for (let i = m - 1; i >= 0; i--) {
sumHints += hints[i];
const separators = m - i - 1;
suffixMin[i] = sumHints + separators;
}
return suffixMin;
};
const solveLineLogic = (lineState, hints) => { const solveLineLogic = (lineState, hints) => {
const n = lineState.length; // Map Store format (0=Unk, 1=Fill, 2=Cross) to Solver format (-1=Unk, 1=Fill, 0=Empty)
const m = hints.length; const solverLine = lineState.map(cell => {
if (m === 0) { if (cell === 0) return -1; // Unknown
for (let i = 0; i < n; i++) { if (cell === 1) return 1; // Filled
if (lineState[i] === 0) return { index: i, state: 2 }; if (cell === 2) return 0; // Empty/Cross
} return -1;
return { index: -1 }; });
}
const { filled, cross } = buildPrefix(lineState); // Call robust solver
const suffixMin = buildSuffixMin(hints); const resultLine = solveLine(solverLine, hints);
const hasFilled = (a, b) => filled[b] - filled[a] > 0; // Check for new info
const hasCross = (a, b) => cross[b] - cross[a] > 0; if (!resultLine) return { index: -1 }; // Contradiction or error
const memoSuffix = Array.from({ length: n + 1 }, () => Array(m + 1).fill(null)); for (let i = 0; i < lineState.length; i++) {
const memoPrefix = Array.from({ length: n + 1 }, () => Array(m + 1).fill(null)); // We only care about cells that are currently 0 (Unknown) in Store
if (lineState[i] === 0) {
const canPlaceSuffix = (pos, hintIndex) => { if (resultLine[i] === 1) {
const cached = memoSuffix[pos][hintIndex]; return { index: i, state: 1 }; // Suggest Fill
if (cached !== null) return cached;
if (hintIndex === m) {
const result = !hasFilled(pos, n);
memoSuffix[pos][hintIndex] = result;
return result;
}
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];
for (let start = pos; start <= maxStart; start++) {
if (hasFilled(pos, start)) continue; // Must be empty before this block
if (hasCross(start, start + len)) continue; // Block space must be free of crosses
// 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)) {
memoSuffix[pos][hintIndex] = 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;
} }
} if (resultLine[i] === 0) {
memoSuffix[pos][hintIndex] = false; return { index: i, state: 2 }; // Suggest Cross
return false;
};
const canPlacePrefix = (pos, hintCount) => {
const cached = memoPrefix[pos][hintCount];
if (cached !== null) return cached;
if (hintCount === 0) {
const result = !hasFilled(0, pos);
memoPrefix[pos][hintCount] = result;
return result;
}
const len = hints[hintCount - 1];
// 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--) {
if (hasCross(start, start + len)) continue;
if (hasFilled(start + len, pos)) continue; // Must be empty after this block up to pos
// Check gap before
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)) {
memoPrefix[pos][hintCount] = 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;
return false;
};
const possibleStarts = [];
for (let i = 0; i < m; i++) {
const len = hints[i];
const starts = [];
for (let start = 0; start <= n - len; start++) {
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 (start + len < n && lineState[start + len] === 1) continue;
const nextPos = start + len < n ? start + len + 1 : start + len;
if (!canPlaceSuffix(nextPos, i + 1)) continue;
starts.push(start);
}
possibleStarts.push(starts);
}
const mustFill = new Array(n).fill(false);
const coverage = new Array(n).fill(false);
for (let i = 0; i < m; i++) {
const starts = possibleStarts[i];
const len = hints[i];
if (starts.length === 0) return { index: -1 };
let earliest = starts[0];
let latest = starts[0];
for (let j = 1; j < starts.length; j++) {
earliest = Math.min(earliest, starts[j]);
latest = Math.max(latest, starts[j]);
}
const startOverlap = Math.max(earliest, latest);
const endOverlap = Math.min(earliest + len - 1, latest + len - 1);
for (let k = startOverlap; k <= endOverlap; k++) {
if (k >= 0 && k < n) mustFill[k] = true;
}
for (let s = 0; s < starts.length; s++) {
const start = starts[s];
for (let k = start; k < start + len; k++) {
coverage[k] = true;
} }
} }
} }
for (let i = 0; i < n; i++) {
if (lineState[i] === 0 && mustFill[i]) return { index: i, state: 1 };
}
for (let i = 0; i < n; i++) {
if (lineState[i] === 0 && !coverage[i]) return { index: i, state: 2 };
}
return { index: -1 }; return { index: -1 };
}; };

View File

@@ -5,7 +5,8 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
define: { define: {
'__APP_VERSION__': JSON.stringify(process.env.npm_package_version) '__APP_VERSION__': JSON.stringify(process.env.npm_package_version),
'__APP_HOMEPAGE__': JSON.stringify(process.env.npm_package_homepage)
}, },
plugins: [ plugins: [
vue(), vue(),
@@ -30,14 +31,14 @@ export default defineConfig({
theme_color: '#00f2fe', theme_color: '#00f2fe',
icons: [ icons: [
{ {
src: '/pwa-192x192.svg', src: '/pwa-192x192.png',
sizes: '192x192', sizes: '192x192',
type: 'image/svg+xml' type: 'image/png'
}, },
{ {
src: '/pwa-512x512.svg', src: '/pwa-512x512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/svg+xml' type: 'image/png'
} }
] ]
} }
@@ -47,5 +48,8 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
} }
},
server: {
allowedHosts: true
} }
}) })