39 Commits

Author SHA1 Message Date
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
33 changed files with 13725 additions and 241 deletions

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.0dmrmul42fg"
}], {});
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

@@ -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.12.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.9.7", "version": "1.12.5",
"dependencies": { "dependencies": {
"fireworks-js": "^2.10.8", "fireworks-js": "^2.10.8",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",

View File

@@ -1,9 +1,10 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.9.7", "version": "1.12.5",
"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

@@ -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

@@ -23,11 +23,27 @@ 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 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 (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');
}); });
@@ -94,6 +110,8 @@ 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);
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 +132,7 @@ onMounted(() => {
} else if (displayModeMedia?.addListener) { } else if (displayModeMedia?.addListener) {
displayModeMedia.addListener(updateStandalone); displayModeMedia.addListener(updateStandalone);
} }
window.addEventListener('keydown', onKeyDownGlobal);
} }
}); });
@@ -131,6 +150,7 @@ onUnmounted(() => {
} else if (displayModeMedia?.removeListener) { } else if (displayModeMedia?.removeListener) {
displayModeMedia.removeListener(updateStandalone); displayModeMedia.removeListener(updateStandalone);
} }
window.removeEventListener('keydown', onKeyDownGlobal);
}); });
</script> </script>
@@ -143,10 +163,13 @@ onUnmounted(() => {
/> />
<FixedBar /> <FixedBar />
<div v-if="canInstall && !installDismissed" class="install-banner"> <div v-if="(canInstall || (isIos && !isStandalone)) && !installDismissed" class="install-banner">
<div class="install-text">{{ t('pwa.installTitle') }}</div> <div class="install-text">
<span v-if="isIos && !isStandalone">{{ t('pwa.installIos') }}</span>
<span v-else>{{ t('pwa.installTitle') }}</span>
</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">×</button>

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>

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

@@ -27,17 +27,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;
}
} }
}; };
@@ -175,21 +186,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 +227,10 @@ watch(() => store.size, async () => {
await nextTick(); await nextTick();
computeCellSize(); computeCellSize();
checkScroll(); checkScroll();
setTimeout(() => {
computeCellSize();
checkScroll();
}, 300);
}); });
</script> </script>
@@ -272,6 +304,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"
@@ -111,4 +115,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

@@ -321,8 +321,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">
@@ -402,7 +404,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 +422,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;

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

@@ -63,21 +63,20 @@ 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: [86, 73, 74, 80, 88, 98, 99, 99, 100],
10: [57, 20, 16, 54, 92, 100, 100, 100, 100], 10: [57, 22, 19, 44, 86, 99, 100, 100, 100],
15: [37, 10, 2, 12, 68, 100, 100, 100, 100], 15: [37, 7, 2, 12, 70, 99, 100, 100, 100],
20: [23, 3, 1, 2, 37, 100, 100, 100, 100], 20: [23, 3, 0, 3, 40, 99, 100, 100, 100],
25: [16, 0, 0, 1, 19, 99, 100, 100, 100], 25: [13, 1, 0, 1, 19, 99, 100, 100, 100],
30: [8, 0, 0, 0, 5, 99, 100, 100, 100], 30: [8, 1, 0, 0, 4, 100, 100, 100, 100],
35: [6, 0, 0, 0, 4, 91, 100, 100, 100], 35: [5, 0, 0, 0, 3, 99, 100, 100, 100],
40: [3, 0, 0, 0, 2, 91, 100, 100, 100], 40: [3, 0, 0, 0, 1, 96, 100, 100, 100],
45: [2, 0, 0, 0, 1, 82, 100, 100, 100], 45: [2, 0, 0, 0, 1, 83, 100, 100, 100],
50: [2, 0, 0, 0, 1, 73, 100, 100, 100], 50: [1, 0, 0, 0, 0, 62, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100], 60: [0, 0, 0, 0, 0, 18, 100, 100, 100],
71: [0, 0, 0, 0, 0, 16, 100, 100, 100], 70: [0, 0, 0, 0, 0, 14, 100, 100, 100],
80: [0, 0, 0, 0, 0, 1, 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 +121,32 @@ 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'; if (solvedPct < 99) {
if (value >= 90) level = 'extreme'; // < 10% Solved // Extreme: Requires guessing
else if (value >= 60) level = 'hardest'; // < 40% Solved level = 'extreme';
else if (value >= 30) level = 'harder'; // < 70% Solved // Map 0-99% solved to value 85-100
else level = 'easy'; // > 70% Solved value = 85 + ((99 - solvedPct) / 99) * 15;
} else {
// Solvable (>= 99%)
// Density factor: 0.5 is hardest (1), 0.1/0.9 is easiest (0.2)
const densityFactor = 1 - 2 * Math.abs(density - 0.5);
return { level, value }; // Complexity based on Size and Density
// Max size 80.
// Formula: size * (0.4 + 0.6 * densityFactor)
// Max: 80 * 1 = 80.
const complexity = size * (0.4 + 0.6 * densityFactor);
value = Math.min(85, complexity);
if (value < 25) level = 'easy';
else if (value < 55) level = 'harder';
else level = 'hardest';
}
return { level, value: Math.round(value) };
} }

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

@@ -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
} }
}) })