Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ef4b2653a8
|
|||
|
3237fcfa7c
|
|||
|
fcef986d2b
|
|||
|
4c6f4d5865
|
|||
|
3052fed279
|
|||
|
fa983c6777
|
|||
|
2a1ddf92aa
|
|||
|
a5b93c1198
|
|||
|
be014fc3c8
|
|||
|
269db48aec
|
|||
|
fe72508717
|
|||
|
70ba5ebdb3
|
|||
|
5aaa8c7357
|
|||
|
bf7cc4cb1d
|
|||
|
1aff1a0857
|
|||
|
a59aeb93e6
|
|||
|
8291c8f74c
|
|||
|
0f240596cc
|
|||
|
6fc3e1c057
|
|||
|
d488531358
|
|||
|
e3d2b630e1
|
|||
|
1392a9f3d5
|
|||
|
8ccf5de972
|
|||
|
ddf6a31f55
|
|||
|
62f35ec35e
|
|||
|
cdaa10c282
|
|||
|
56bff9fc59
|
|||
|
ff520b53f7
|
|||
|
c5e09a51e8
|
|||
|
222b8a5f4c
|
|||
|
cf84dfd7f2
|
|||
|
99d1370461
|
|||
|
01b01b727f
|
|||
|
46bde27514
|
|||
| 739c2b21d7 | |||
| 0d4ef75934 | |||
| b6e685d351 | |||
|
44b0f6443f
|
|||
|
a926727b51
|
|||
| 4782d20493 | |||
| 182658774e | |||
| 635fdb089d |
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Optional: Set a custom container name to run multiple instances
|
||||
# CONTAINER_NAME=nonograms-dev
|
||||
@@ -1,6 +1,4 @@
|
||||
name: Deploy to Production
|
||||
run-name: Deploy to Production by @${{ github.actor }}
|
||||
|
||||
name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -10,14 +8,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and deploy with Docker Compose
|
||||
- name: Deploy nonograms-app
|
||||
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
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
||||
.DS_Store
|
||||
dist/
|
||||
dist-ssr/
|
||||
dev-dist/
|
||||
*.local
|
||||
.npmrc
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t 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.n1n8rjsg38"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,22 @@
|
||||
version: '3.8'
|
||||
name: nonograms-app
|
||||
|
||||
services:
|
||||
nonograms:
|
||||
container_name: nonograms
|
||||
nonograms-app:
|
||||
container_name: nonograms-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# ports:
|
||||
# - "8083:80"
|
||||
expose:
|
||||
- "80"
|
||||
restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- npm_public
|
||||
npm_public:
|
||||
aliases:
|
||||
- nonograms-app
|
||||
|
||||
networks:
|
||||
npm_public:
|
||||
|
||||
16
index.html
16
index.html
@@ -6,7 +6,21 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/nonograms.svg" color="#00f2fe" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Nonograms Pro - Vue 3 SOLID</title>
|
||||
|
||||
<title>Nonograms Pro - Logic Puzzle Game</title>
|
||||
<meta name="description" content="Interactive Nonogram logic puzzles with a modern interface. Solve puzzles, use the built-in solver, and create your own custom games.">
|
||||
|
||||
<!-- Open Graph (for social media sharing like Facebook/Discord) -->
|
||||
<meta property="og:title" content="Nonograms Pro - Logic Puzzle Game">
|
||||
<meta property="og:description" content="Interactive Nonogram logic puzzles with a modern interface. Solve puzzles, use the built-in solver, and create your own custom games.">
|
||||
<meta property="og:image" content="/screenshot.png">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<!-- Twitter Cards -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Nonograms Pro - Logic Puzzle Game">
|
||||
<meta name="twitter:description" content="Interactive Nonogram logic puzzles with a modern interface. Solve puzzles, use the built-in solver, and create your own custom games.">
|
||||
<meta name="twitter:image" content="/screenshot.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.17",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.1.0",
|
||||
"@capacitor/cli": "^8.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.17",
|
||||
"homepage": "https://nonograms.7u.pl/",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
import { HelpCircle } from 'lucide-vue-next';
|
||||
import DifficultyMap from './DifficultyMap.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'open-simulation']);
|
||||
const store = usePuzzleStore();
|
||||
@@ -12,161 +13,11 @@ const { t } = useI18n();
|
||||
const customSize = ref(10);
|
||||
const fillRate = ref(50);
|
||||
const errorMsg = ref('');
|
||||
const difficultyCanvas = ref(null);
|
||||
const isDragging = ref(false);
|
||||
const cachedBackground = ref(null);
|
||||
|
||||
const drawMap = () => {
|
||||
const canvas = difficultyCanvas.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Use cached background if available
|
||||
if (cachedBackground.value) {
|
||||
ctx.putImageData(cachedBackground.value, 0, 0);
|
||||
} else {
|
||||
// Draw Gradient Background (Heavy calculation)
|
||||
const imgData = ctx.createImageData(width, height);
|
||||
const data = imgData.data;
|
||||
|
||||
// Ranges:
|
||||
// X: Fill Rate 10% -> 90%
|
||||
// Y: Size 5 -> 80
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const normalizedX = x / width;
|
||||
const normalizedY = 1 - (y / height); // 0 at bottom, 1 at top
|
||||
|
||||
const fRate = 0.1 + normalizedX * 0.8; // 0.1 to 0.9
|
||||
const sSize = 5 + normalizedY * 75; // 5 to 80
|
||||
|
||||
const { value } = calculateDifficulty(fRate, sSize);
|
||||
|
||||
// Color Mapping
|
||||
const hue = 120 * (1 - value / 100);
|
||||
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
|
||||
|
||||
const index = (y * width + x) * 4;
|
||||
data[index] = r;
|
||||
data[index + 1] = g;
|
||||
data[index + 2] = b;
|
||||
data[index + 3] = 255; // Alpha
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
cachedBackground.value = imgData;
|
||||
}
|
||||
|
||||
// Draw current position
|
||||
// Map current fillRate/size to x,y
|
||||
// Fill: 10..90. Size: 5..80.
|
||||
const currentFill = Math.max(10, Math.min(90, fillRate.value));
|
||||
const currentSize = Math.max(5, Math.min(80, customSize.value));
|
||||
|
||||
const posX = ((currentFill - 10) / 80) * width;
|
||||
const posY = (1 - (currentSize - 5) / 75) * height;
|
||||
|
||||
// Draw Crosshair/Circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(posX, posY, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const hslToRgb = (h, s, l) => {
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
};
|
||||
|
||||
const updateFromEvent = (e) => {
|
||||
const canvas = difficultyCanvas.value;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
// Handle Touch or Mouse
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
let x = clientX - rect.left;
|
||||
let y = clientY - rect.top;
|
||||
|
||||
// Clamp
|
||||
x = Math.max(0, Math.min(rect.width, x));
|
||||
y = Math.max(0, Math.min(rect.height, y));
|
||||
|
||||
// Reverse Map
|
||||
// x / width -> fillRate (10..90)
|
||||
// 1 - y / height -> size (5..80)
|
||||
|
||||
const normalizedX = x / rect.width;
|
||||
const normalizedY = 1 - (y / rect.height);
|
||||
|
||||
const newFill = 10 + normalizedX * 80;
|
||||
const newSize = 5 + normalizedY * 75;
|
||||
|
||||
fillRate.value = Math.round(newFill);
|
||||
customSize.value = Math.round(newSize);
|
||||
};
|
||||
|
||||
const startDrag = (e) => {
|
||||
isDragging.value = true;
|
||||
updateFromEvent(e);
|
||||
// Add global listeners for mouse to handle dragging outside canvas
|
||||
window.addEventListener('mousemove', onDrag);
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
};
|
||||
|
||||
const onDrag = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
updateFromEvent(e);
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
window.removeEventListener('mousemove', onDrag);
|
||||
window.removeEventListener('mouseup', stopDrag);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onDrag);
|
||||
window.removeEventListener('mouseup', stopDrag);
|
||||
});
|
||||
|
||||
const showAdvanced = ref(false);
|
||||
const showAdvanced = ref(true);
|
||||
|
||||
const toggleAdvanced = () => {
|
||||
showAdvanced.value = !showAdvanced.value;
|
||||
if (showAdvanced.value) {
|
||||
// Reset cache when opening to ensure size is correct if canvas resized
|
||||
cachedBackground.value = null;
|
||||
nextTick(drawMap);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -179,14 +30,6 @@ onMounted(() => {
|
||||
if (savedFillRate && !isNaN(savedFillRate)) {
|
||||
fillRate.value = Math.max(10, Math.min(90, Number(savedFillRate)));
|
||||
}
|
||||
|
||||
// Don't draw map initially if hidden
|
||||
});
|
||||
|
||||
watch([customSize, fillRate], () => {
|
||||
if (showAdvanced.value) {
|
||||
drawMap();
|
||||
}
|
||||
});
|
||||
|
||||
watch(customSize, (newVal) => {
|
||||
@@ -245,7 +88,7 @@ const confirm = () => {
|
||||
<div class="range-value">{{ customSize }}</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model="customSize"
|
||||
v-model.number="customSize"
|
||||
min="5"
|
||||
max="80"
|
||||
step="1"
|
||||
@@ -264,7 +107,7 @@ const confirm = () => {
|
||||
<div class="range-value">{{ fillRate }}%</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model="fillRate"
|
||||
v-model.number="fillRate"
|
||||
min="10"
|
||||
max="90"
|
||||
step="1"
|
||||
@@ -278,15 +121,14 @@ const confirm = () => {
|
||||
</div>
|
||||
|
||||
<div class="map-section" v-if="showAdvanced">
|
||||
<canvas
|
||||
ref="difficultyCanvas"
|
||||
width="400"
|
||||
height="400"
|
||||
@mousedown="startDrag"
|
||||
@touchstart.prevent="startDrag"
|
||||
@touchmove.prevent="onDrag"
|
||||
@touchend="stopDrag"
|
||||
></canvas>
|
||||
<DifficultyMap
|
||||
v-model:size="customSize"
|
||||
v-model:density="fillRate"
|
||||
:interactive="true"
|
||||
:width="400"
|
||||
:height="400"
|
||||
class="difficulty-map-canvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
293
src/components/DifficultyMap.vue
Normal file
293
src/components/DifficultyMap.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
density: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
actualDifficulty: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
interactive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 300
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:size', 'update:density']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const canvasRef = ref(null);
|
||||
let cachedBackground = null;
|
||||
const isDragging = ref(false);
|
||||
|
||||
// Constants for ranges
|
||||
const MIN_SIZE = 5;
|
||||
const MAX_SIZE = 80;
|
||||
const MIN_DENSITY = 10;
|
||||
const MAX_DENSITY = 90;
|
||||
|
||||
const hslToRgb = (h, s, l) => {
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
};
|
||||
|
||||
const drawMap = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Use cached background if available
|
||||
if (cachedBackground) {
|
||||
ctx.drawImage(cachedBackground, 0, 0, width, height);
|
||||
} else {
|
||||
// Draw Gradient Background (Optimized)
|
||||
// Use a smaller buffer to reduce calculations
|
||||
const bufferWidth = 40;
|
||||
const bufferHeight = 40;
|
||||
|
||||
const bufferCanvas = document.createElement('canvas');
|
||||
bufferCanvas.width = bufferWidth;
|
||||
bufferCanvas.height = bufferHeight;
|
||||
const bufferCtx = bufferCanvas.getContext('2d');
|
||||
|
||||
const imgData = bufferCtx.createImageData(bufferWidth, bufferHeight);
|
||||
const data = imgData.data;
|
||||
|
||||
for (let y = 0; y < bufferHeight; y++) {
|
||||
for (let x = 0; x < bufferWidth; x++) {
|
||||
const normalizedX = x / bufferWidth;
|
||||
const normalizedY = 1 - (y / bufferHeight); // 0 at bottom, 1 at top
|
||||
|
||||
const fRate = (MIN_DENSITY + normalizedX * (MAX_DENSITY - MIN_DENSITY)) / 100; // 0.1 to 0.9
|
||||
const sSize = MIN_SIZE + normalizedY * (MAX_SIZE - MIN_SIZE); // 5 to 80
|
||||
|
||||
const { value } = calculateDifficulty(fRate, sSize);
|
||||
|
||||
// Color Mapping
|
||||
const hue = 120 * (1 - value / 100);
|
||||
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
|
||||
|
||||
const index = (y * bufferWidth + x) * 4;
|
||||
data[index] = r;
|
||||
data[index + 1] = g;
|
||||
data[index + 2] = b;
|
||||
data[index + 3] = 255; // Alpha
|
||||
}
|
||||
}
|
||||
bufferCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
// Draw scaled up
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bufferCanvas, 0, 0, width, height);
|
||||
|
||||
cachedBackground = bufferCanvas;
|
||||
}
|
||||
|
||||
// Draw current position
|
||||
// Clamp values
|
||||
const currentFill = Math.max(MIN_DENSITY, Math.min(MAX_DENSITY, props.density));
|
||||
const currentSize = Math.max(MIN_SIZE, Math.min(MAX_SIZE, props.size));
|
||||
|
||||
const posX = ((currentFill - MIN_DENSITY) / (MAX_DENSITY - MIN_DENSITY)) * width;
|
||||
const posY = (1 - (currentSize - MIN_SIZE) / (MAX_SIZE - MIN_SIZE)) * height;
|
||||
|
||||
// Draw Point
|
||||
ctx.beginPath();
|
||||
ctx.arc(posX, posY, 6, 0, Math.PI * 2);
|
||||
|
||||
// Use actual difficulty color if provided, otherwise white
|
||||
if (props.actualDifficulty !== null) {
|
||||
const hue = 120 * (1 - Math.max(0, Math.min(100, props.actualDifficulty)) / 100);
|
||||
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.lineWidth = 3; // Thicker border for visibility
|
||||
ctx.strokeStyle = '#fff'; // White border to make it pop
|
||||
} else {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#000';
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} catch (e) {
|
||||
console.error("Error drawing difficulty map:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFromEvent = (e) => {
|
||||
if (!props.interactive) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
// Handle Touch or Mouse
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
let x = clientX - rect.left;
|
||||
let y = clientY - rect.top;
|
||||
|
||||
// Clamp
|
||||
x = Math.max(0, Math.min(rect.width, x));
|
||||
y = Math.max(0, Math.min(rect.height, y));
|
||||
|
||||
const normalizedX = x / rect.width;
|
||||
const normalizedY = 1 - (y / rect.height);
|
||||
|
||||
const newFill = MIN_DENSITY + normalizedX * (MAX_DENSITY - MIN_DENSITY);
|
||||
const newSize = MIN_SIZE + normalizedY * (MAX_SIZE - MIN_SIZE);
|
||||
|
||||
emit('update:density', Math.round(newFill));
|
||||
emit('update:size', Math.round(newSize));
|
||||
};
|
||||
|
||||
const startDrag = (e) => {
|
||||
if (!props.interactive) return;
|
||||
isDragging.value = true;
|
||||
updateFromEvent(e);
|
||||
window.addEventListener('mousemove', onDrag);
|
||||
window.addEventListener('touchmove', onDrag, { passive: false });
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
window.addEventListener('touchend', stopDrag);
|
||||
};
|
||||
|
||||
const onDrag = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
if (e.cancelable) e.preventDefault(); // Prevent scrolling on touch
|
||||
updateFromEvent(e);
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
window.removeEventListener('mousemove', onDrag);
|
||||
window.removeEventListener('touchmove', onDrag);
|
||||
window.removeEventListener('mouseup', stopDrag);
|
||||
window.removeEventListener('touchend', stopDrag);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(drawMap);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopDrag(); // Cleanup just in case
|
||||
});
|
||||
|
||||
watch(() => [props.size, props.density, props.actualDifficulty, props.width, props.height], () => {
|
||||
requestAnimationFrame(drawMap);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="difficulty-map-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDrag"
|
||||
:class="{ 'interactive': interactive }"
|
||||
></canvas>
|
||||
|
||||
<div class="axis-labels">
|
||||
<span class="y-label">{{ t('difficultyMap.size') }}</span>
|
||||
<span class="x-label">{{ t('difficultyMap.density') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-map-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 1px solid var(--panel-border, #444);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
display: block; /* Remove inline gap */
|
||||
width: 100%; /* Responsive */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
canvas.interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.axis-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.y-label {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
transform-origin: top left;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 2px rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.x-label {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 2px rgba(255,255,255,0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ 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 DifficultyMap from './DifficultyMap.vue';
|
||||
import { Upload, Image as ImageIcon, X, AlertTriangle, Camera, RefreshCw } from 'lucide-vue-next';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@@ -386,6 +387,7 @@ const capturePhoto = () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCameraStream();
|
||||
getWorkerPool().cancelAll();
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -492,6 +494,17 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="imageLoaded" class="stats-panel">
|
||||
<div class="difficulty-map-wrapper">
|
||||
<DifficultyMap
|
||||
:size="maxDimension"
|
||||
:density="threshold"
|
||||
:actual-difficulty="difficulty > 0 ? difficulty : null"
|
||||
:interactive="false"
|
||||
:width="200"
|
||||
:height="200"
|
||||
class="difficulty-map-mini"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="processing" class="loading-stats">
|
||||
<div class="spinner"></div>
|
||||
<span>{{ t('image.calculatingSolvability') || 'Calculating solvability...' }} {{ processingProgress }}%</span>
|
||||
@@ -531,6 +544,24 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-map-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.difficulty-map-mini :deep(canvas) {
|
||||
width: 100% !important;
|
||||
max-width: 200px;
|
||||
height: auto !important;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -626,13 +657,14 @@ onUnmounted(() => {
|
||||
|
||||
.camera-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
padding-bottom: 40px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@ const handleKeyDown = (e) => {
|
||||
|
||||
const triggerVibration = () => {
|
||||
if (!('vibrate' in navigator)) return;
|
||||
const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches;
|
||||
const isTouch = navigator.maxTouchPoints && navigator.maxTouchPoints > 0;
|
||||
if (isCoarse || isTouch) {
|
||||
navigator.vibrate([80, 40, 120, 40, 180]);
|
||||
try {
|
||||
const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches;
|
||||
const isTouch = navigator.maxTouchPoints && navigator.maxTouchPoints > 0;
|
||||
if (isCoarse || isTouch) {
|
||||
navigator.vibrate([200, 100, 200, 100, 200]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vibration failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -197,7 +197,17 @@ const messages = {
|
||||
'simulation.table.size': 'Rozmiar',
|
||||
'simulation.table.density': 'Gęstość',
|
||||
'simulation.table.solved': 'Rozwiązano (Logika)',
|
||||
'simulation.empty': 'Naciśnij Start, aby uruchomić symulację Monte Carlo'
|
||||
'simulation.empty': 'Naciśnij Start, aby uruchomić symulację Monte Carlo',
|
||||
'difficultyMap.size': 'Rozmiar',
|
||||
'difficultyMap.density': 'Gęstość',
|
||||
'worker.solved': 'Rozwiązane!',
|
||||
'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}',
|
||||
'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}',
|
||||
'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.',
|
||||
'worker.boosted': 'Boost (DFS): Wiersz {row}, Kolumna {col} -> {state}',
|
||||
'worker.done': 'Koniec!',
|
||||
'worker.state.filled': 'Pełne',
|
||||
'worker.state.empty': 'Puste'
|
||||
},
|
||||
en: {
|
||||
'app.title': 'Nonograms',
|
||||
@@ -395,7 +405,17 @@ const messages = {
|
||||
'simulation.table.solved': 'Solved (Logic)',
|
||||
'simulation.empty': 'Press Start to run Monte Carlo simulation',
|
||||
'custom.hideMap': 'Hide difficulty map',
|
||||
'custom.showMap': 'Show difficulty map'
|
||||
'custom.showMap': 'Show difficulty map',
|
||||
'difficultyMap.size': 'Size',
|
||||
'difficultyMap.density': 'Density',
|
||||
'worker.solved': 'Solved!',
|
||||
'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}',
|
||||
'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}',
|
||||
'worker.stuck': 'No logical move found. Try guessing or undoing.',
|
||||
'worker.boosted': 'Boost (DFS): Row {row}, Column {col} -> {state}',
|
||||
'worker.done': 'Done!',
|
||||
'worker.state.filled': 'Filled',
|
||||
'worker.state.empty': 'Empty'
|
||||
},
|
||||
zh: {
|
||||
'app.title': 'Nonograms',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { ref, computed, onUnmounted, watch } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
@@ -18,6 +18,24 @@ export function useSolver() {
|
||||
let worker = null;
|
||||
let requestId = 0;
|
||||
|
||||
// Reset solver state when game resets or changes
|
||||
watch(() => store.currentLevelId, () => {
|
||||
resetSolverState();
|
||||
});
|
||||
|
||||
watch(() => store.moves, (newVal) => {
|
||||
if (newVal === 0) {
|
||||
resetSolverState();
|
||||
}
|
||||
});
|
||||
|
||||
function resetSolverState() {
|
||||
pause();
|
||||
isStuck.value = false;
|
||||
statusText.value = t('guide.waiting');
|
||||
isProcessing.value = false;
|
||||
}
|
||||
|
||||
function step() {
|
||||
if (store.isGameWon) {
|
||||
pause();
|
||||
@@ -79,10 +97,20 @@ export function useSolver() {
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker) return;
|
||||
worker = new Worker(new URL('../workers/solverWorker.js', import.meta.url), { type: 'module' });
|
||||
worker = new Worker(new URL('../workers/solver.worker.js', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event) => {
|
||||
const { type, r, c, state, statusText: text } = event.data;
|
||||
if (text) statusText.value = text;
|
||||
const { type, r, c, state, status } = event.data;
|
||||
if (status) {
|
||||
const params = status.params ? { ...status.params } : {};
|
||||
if (params.stateKey) {
|
||||
params.state = t(params.stateKey);
|
||||
}
|
||||
statusText.value = t(status.key, params);
|
||||
} else if (event.data.statusText) {
|
||||
// Fallback for legacy messages if any
|
||||
statusText.value = event.data.statusText;
|
||||
}
|
||||
|
||||
if (type === 'move') {
|
||||
store.setCell(r, c, state);
|
||||
isProcessing.value = false;
|
||||
|
||||
@@ -376,7 +376,7 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
if (currentLevelId.value === 'custom') {
|
||||
if (currentLevelId.value === 'custom' || currentLevelId.value === 'custom_image') {
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
@@ -432,6 +432,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
guideUsageCount,
|
||||
currentDensity,
|
||||
markGuideUsed,
|
||||
markBoostUsed,
|
||||
hasUsedBoost,
|
||||
boostUsageCount,
|
||||
startInteraction,
|
||||
endInteraction,
|
||||
completedRows,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
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.difficultyScore).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -61,39 +61,43 @@ export function generateRandomGrid(size, density = 0.5) {
|
||||
return grid;
|
||||
}
|
||||
|
||||
// Data derived from Monte Carlo Simulation (Logical Solver)
|
||||
// Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] }
|
||||
const SIM_DATA = {
|
||||
5: [88, 76, 71, 80, 90, 98, 99, 100, 100],
|
||||
10: [58, 25, 18, 44, 81, 99, 100, 100, 100],
|
||||
15: [36, 7, 3, 11, 67, 99, 100, 100, 100],
|
||||
20: [24, 3, 0, 3, 48, 99, 100, 100, 100],
|
||||
25: [13, 1, 0, 1, 21, 99, 100, 100, 100],
|
||||
30: [9, 0, 0, 0, 7, 99, 100, 100, 100],
|
||||
35: [5, 0, 0, 0, 5, 97, 100, 100, 100],
|
||||
40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
|
||||
45: [2, 0, 0, 0, 1, 84, 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],
|
||||
65: [0, 0, 0, 0, 0, 20, 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]
|
||||
};
|
||||
|
||||
const SIM_SIZES = Object.keys(SIM_DATA).map(Number).sort((a, b) => a - b);
|
||||
|
||||
export function calculateDifficulty(density, size = 10) {
|
||||
// Data derived from Monte Carlo Simulation (Logical Solver)
|
||||
// Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] }
|
||||
const SIM_DATA = {
|
||||
5: [88, 76, 71, 80, 90, 98, 99, 100, 100],
|
||||
10: [58, 25, 18, 44, 81, 99, 100, 100, 100],
|
||||
15: [36, 7, 3, 11, 67, 99, 100, 100, 100],
|
||||
20: [24, 3, 0, 3, 48, 99, 100, 100, 100],
|
||||
25: [13, 1, 0, 1, 21, 99, 100, 100, 100],
|
||||
30: [9, 0, 0, 0, 7, 99, 100, 100, 100],
|
||||
35: [5, 0, 0, 0, 5, 97, 100, 100, 100],
|
||||
40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
|
||||
45: [2, 0, 0, 0, 1, 84, 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],
|
||||
65: [0, 0, 0, 0, 0, 20, 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]
|
||||
};
|
||||
density = Number(density);
|
||||
size = Number(size);
|
||||
|
||||
// Helper to get interpolated value from array
|
||||
const getSimulatedSolvedPct = (s, d) => {
|
||||
// Find closest sizes
|
||||
const sizes = Object.keys(SIM_DATA).map(Number).sort((a, b) => a - b);
|
||||
let sLower = sizes[0];
|
||||
let sUpper = sizes[sizes.length - 1];
|
||||
let sLower = SIM_SIZES[0];
|
||||
let sUpper = SIM_SIZES[SIM_SIZES.length - 1];
|
||||
|
||||
for (let i = 0; i < sizes.length - 1; i++) {
|
||||
if (s >= sizes[i] && s <= sizes[i+1]) {
|
||||
sLower = sizes[i];
|
||||
sUpper = sizes[i+1];
|
||||
for (let i = 0; i < SIM_SIZES.length - 1; i++) {
|
||||
if (s >= SIM_SIZES[i] && s <= SIM_SIZES[i+1]) {
|
||||
sLower = SIM_SIZES[i];
|
||||
sUpper = SIM_SIZES[i+1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,8 +38,6 @@ export function buildShareCanvas(data, t, formattedTime) {
|
||||
bg.addColorStop(1, '#0a1324');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
|
||||
ctx.fillRect(12, 12, width - 24, height - 24);
|
||||
|
||||
ctx.fillStyle = '#e8fbff';
|
||||
ctx.font = '700 26px "Segoe UI", sans-serif';
|
||||
@@ -204,7 +202,6 @@ export function buildShareSVG(data, t, formattedTime) {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<rect x="12" y="12" width="${width - 24}" height="${height - 24}" fill="${overlayColor}"/>
|
||||
`;
|
||||
|
||||
// Text: Title & Time
|
||||
@@ -253,8 +250,12 @@ export function buildShareSVG(data, t, formattedTime) {
|
||||
} else if (state === 2) { // Cross
|
||||
const d = cellSize * 0.6;
|
||||
const off = cellSize * 0.2;
|
||||
const x1 = (cx + off).toFixed(1);
|
||||
const y1 = (cy + off).toFixed(1);
|
||||
const x2 = (cx + off + d).toFixed(1);
|
||||
const y2 = (cy + off + d).toFixed(1);
|
||||
cells += `
|
||||
<path d="M${cx + off} ${cy + off} L${cx + off + d} ${cy + off + d} M${cx + off + d} ${cy + off} L${cx + off} ${cy + off + d}"
|
||||
<path d="M${x1} ${y1} L${x2} ${y2} M${x2} ${y1} L${x1} ${y2}"
|
||||
stroke="${crossColor}" stroke-width="${lineWidth}" stroke-linecap="round"/>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ export function solvePuzzle(rowHints, colHints, onProgress, initialGrid = null,
|
||||
lookaheadUsed: maxDepth > 0,
|
||||
iterations,
|
||||
maxDepth,
|
||||
backtracks
|
||||
backtracks,
|
||||
solution: grid
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { solvePuzzle } from './solver';
|
||||
import { calculateHints, generateRandomGrid } from './puzzleUtils';
|
||||
|
||||
describe('Solver', () => {
|
||||
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.
|
||||
@@ -61,4 +66,81 @@
|
||||
expect(result.percentSolved).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
// Merged from repro_solver.test.js
|
||||
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);
|
||||
});
|
||||
|
||||
// Merged from debug_solver.test.js
|
||||
it('should solve the broken grid (debug case)', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
// Merged from large_grid_solver.test.js
|
||||
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); // Removed console.log callback to reduce noise
|
||||
// console.log('Result:', result);
|
||||
|
||||
expect(result.percentSolved).toBeGreaterThan(0);
|
||||
expect(result.difficultyScore).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,240 @@
|
||||
import { calculateHints } from '../utils/puzzleUtils';
|
||||
import { solvePuzzle } from '../utils/solver';
|
||||
import { calculateHints } from '../utils/puzzleUtils.js';
|
||||
import { solveLine, solvePuzzle } from '../utils/solver.js';
|
||||
|
||||
// --- Logic Helpers ---
|
||||
const solveLineLogic = (lineState, hints) => {
|
||||
// Map Store format (0=Unk, 1=Fill, 2=Cross) to Solver format (-1=Unk, 1=Fill, 0=Empty)
|
||||
const solverLine = lineState.map(cell => {
|
||||
if (cell === 0) return -1; // Unknown
|
||||
if (cell === 1) return 1; // Filled
|
||||
if (cell === 2) return 0; // Empty/Cross
|
||||
return -1;
|
||||
});
|
||||
|
||||
// Call robust solver
|
||||
const resultLine = solveLine(solverLine, hints);
|
||||
|
||||
// Check for new info
|
||||
if (!resultLine) return { index: -1 }; // Contradiction or error
|
||||
|
||||
for (let i = 0; i < lineState.length; i++) {
|
||||
// We only care about cells that are currently 0 (Unknown) in Store
|
||||
if (lineState[i] === 0) {
|
||||
if (resultLine[i] === 1) {
|
||||
return { index: i, state: 1 }; // Suggest Fill
|
||||
}
|
||||
if (resultLine[i] === 0) {
|
||||
return { index: i, state: 2 }; // Suggest Cross
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1 };
|
||||
};
|
||||
|
||||
const isSolved = (grid, solution) => {
|
||||
const size = grid.length;
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const playerCell = grid[r][c];
|
||||
const solutionCell = solution[r][c];
|
||||
const isFilled = playerCell === 1;
|
||||
const shouldBeFilled = solutionCell === 1;
|
||||
|
||||
// Check correctness
|
||||
if (isFilled !== shouldBeFilled) return false;
|
||||
|
||||
// Check completeness (must be fully resolved to 1 or 2)
|
||||
if (playerCell === 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStep = (playerGrid, solution) => {
|
||||
if (isSolved(playerGrid, solution)) {
|
||||
return { type: 'done', status: { key: 'worker.solved' } };
|
||||
}
|
||||
|
||||
const size = solution.length;
|
||||
const { rowHints, colHints } = calculateHints(solution);
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
const rowLine = playerGrid[r];
|
||||
const hints = rowHints[r];
|
||||
const result = solveLineLogic(rowLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateKey = result.state === 1 ? 'worker.state.filled' : 'worker.state.empty';
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c: result.index,
|
||||
state: result.state,
|
||||
status: {
|
||||
key: 'worker.logicRow',
|
||||
params: { row: r + 1, col: result.index + 1, stateKey }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let c = 0; c < size; c++) {
|
||||
const colLine = [];
|
||||
for (let r = 0; r < size; r++) colLine.push(playerGrid[r][c]);
|
||||
const hints = colHints[c];
|
||||
const result = solveLineLogic(colLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateKey = result.state === 1 ? 'worker.state.filled' : 'worker.state.empty';
|
||||
return {
|
||||
type: 'move',
|
||||
r: result.index,
|
||||
c,
|
||||
state: result.state,
|
||||
status: {
|
||||
key: 'worker.logicCol',
|
||||
params: { row: result.index + 1, col: c + 1, stateKey }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'stuck', status: { key: 'worker.stuck' } };
|
||||
};
|
||||
|
||||
const handleBoost = (playerGrid, solution) => {
|
||||
const size = solution.length;
|
||||
|
||||
// 1. Try to use the Solver (DFS) to find a logical move
|
||||
try {
|
||||
const { rowHints, colHints } = calculateHints(solution);
|
||||
|
||||
// Map Store format (0=Unk, 1=Fill, 2=Cross) to Solver format (-1=Unk, 1=Fill, 0=Empty)
|
||||
const solverGrid = playerGrid.map(row => row.map(cell => {
|
||||
if (cell === 0) return -1;
|
||||
if (cell === 1) return 1;
|
||||
if (cell === 2) return 0;
|
||||
return -1;
|
||||
}));
|
||||
|
||||
// Run full solver (logicOnly=false allows DFS/guessing)
|
||||
const result = solvePuzzle(rowHints, colHints, null, solverGrid, false);
|
||||
|
||||
if (result && result.solution) {
|
||||
const solvedGrid = result.solution;
|
||||
|
||||
// Find the first cell that is Unknown in playerGrid but Known in solvedGrid
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (playerGrid[r][c] === 0) { // Unknown in Player
|
||||
const solvedVal = solvedGrid[r][c]; // -1=Unk, 0=Empty, 1=Filled
|
||||
|
||||
if (solvedVal !== -1) {
|
||||
// Found a logical deduction!
|
||||
const newState = solvedVal === 1 ? 1 : 2; // 1->Filled, 0->Cross
|
||||
const stateKey = newState === 1 ? 'worker.state.filled' : 'worker.state.empty';
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c,
|
||||
state: newState,
|
||||
status: {
|
||||
key: 'worker.boosted',
|
||||
params: { row: r + 1, col: c + 1, stateKey }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Boost Solver failed, falling back to simple reveal:', e);
|
||||
}
|
||||
|
||||
// 2. Fallback: Cheat
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (playerGrid[r][c] === 0) {
|
||||
const correctState = solution[r][c] === 1 ? 1 : 2;
|
||||
const stateKey = correctState === 1 ? 'worker.state.filled' : 'worker.state.empty';
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c,
|
||||
state: correctState,
|
||||
status: {
|
||||
key: 'worker.boosted',
|
||||
params: { row: r + 1, col: c + 1, stateKey }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'done', status: { key: 'worker.solved' } };
|
||||
};
|
||||
|
||||
// --- Main Worker Handler ---
|
||||
self.onmessage = (e) => {
|
||||
const { id, grid, initialGrid } = e.data;
|
||||
const { id, grid, initialGrid, playerGrid, solution, action } = e.data;
|
||||
|
||||
try {
|
||||
if (!grid || grid.length === 0) {
|
||||
self.postMessage({ id, error: 'Empty grid' });
|
||||
// Mode 1: Analysis (Batch) - from ImageImportModal / workerPool
|
||||
if (grid) {
|
||||
if (grid.length === 0) {
|
||||
self.postMessage({ id, error: 'Empty grid' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = grid.length;
|
||||
const cols = grid[0].length;
|
||||
const { rowHints, colHints } = calculateHints(grid);
|
||||
|
||||
const onProgress = (percent) => {
|
||||
self.postMessage({
|
||||
id,
|
||||
type: 'progress',
|
||||
percent
|
||||
});
|
||||
};
|
||||
|
||||
const { percentSolved, difficultyScore } = solvePuzzle(rowHints, colHints, onProgress, initialGrid);
|
||||
|
||||
let value = difficultyScore;
|
||||
let level;
|
||||
|
||||
if (percentSolved < 100) {
|
||||
level = 'extreme';
|
||||
} else {
|
||||
if (value < 25) level = 'easy';
|
||||
else if (value < 50) level = 'medium';
|
||||
else if (value < 75) level = 'hard';
|
||||
else level = 'extreme';
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
id,
|
||||
solvability: Math.floor(percentSolved),
|
||||
difficulty: Math.round(value),
|
||||
difficultyLabel: level,
|
||||
rows,
|
||||
cols
|
||||
});
|
||||
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).
|
||||
// Mode 2: Assistant (Step/Boost) - from useSolver
|
||||
if (playerGrid) {
|
||||
let result;
|
||||
if (action === 'boost') {
|
||||
result = handleBoost(playerGrid, solution);
|
||||
} else {
|
||||
result = handleStep(playerGrid, solution);
|
||||
}
|
||||
|
||||
// 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';
|
||||
self.postMessage({ id, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { calculateHints } from '../utils/puzzleUtils.js';
|
||||
import { solveLine } from '../utils/solver.js';
|
||||
|
||||
const messages = {
|
||||
pl: {
|
||||
'worker.solved': 'Rozwiązane!',
|
||||
'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}',
|
||||
'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}',
|
||||
'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.',
|
||||
'worker.boosted': 'Boost (DFS): Wiersz {row}, Kolumna {col} -> {state}',
|
||||
'worker.done': 'Koniec!',
|
||||
'worker.state.filled': 'Pełne',
|
||||
'worker.state.empty': 'Puste'
|
||||
},
|
||||
en: {
|
||||
'worker.solved': 'Solved!',
|
||||
'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}',
|
||||
'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}',
|
||||
'worker.stuck': 'No logical move found. Try guessing or undoing.',
|
||||
'worker.boosted': 'Boost (DFS): Row {row}, Column {col} -> {state}',
|
||||
'worker.done': 'Done!',
|
||||
'worker.state.filled': 'Filled',
|
||||
'worker.state.empty': 'Empty'
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLocale = (value) => {
|
||||
if (!value) return 'en';
|
||||
const short = String(value).toLowerCase().split('-')[0];
|
||||
return short === 'pl' ? 'pl' : 'en';
|
||||
};
|
||||
|
||||
const format = (text, params = {}) => {
|
||||
return text.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = params[key];
|
||||
return value === undefined ? `{${key}}` : String(value);
|
||||
});
|
||||
};
|
||||
|
||||
const t = (locale, key, params) => {
|
||||
const lang = messages[locale] || messages.en;
|
||||
const value = lang[key] || messages.en[key] || key;
|
||||
return typeof value === 'string' ? format(value, params) : key;
|
||||
};
|
||||
|
||||
const solveLineLogic = (lineState, hints) => {
|
||||
// Map Store format (0=Unk, 1=Fill, 2=Cross) to Solver format (-1=Unk, 1=Fill, 0=Empty)
|
||||
const solverLine = lineState.map(cell => {
|
||||
if (cell === 0) return -1; // Unknown
|
||||
if (cell === 1) return 1; // Filled
|
||||
if (cell === 2) return 0; // Empty/Cross
|
||||
return -1;
|
||||
});
|
||||
|
||||
// Call robust solver
|
||||
const resultLine = solveLine(solverLine, hints);
|
||||
|
||||
// Check for new info
|
||||
if (!resultLine) return { index: -1 }; // Contradiction or error
|
||||
|
||||
for (let i = 0; i < lineState.length; i++) {
|
||||
// We only care about cells that are currently 0 (Unknown) in Store
|
||||
if (lineState[i] === 0) {
|
||||
if (resultLine[i] === 1) {
|
||||
return { index: i, state: 1 }; // Suggest Fill
|
||||
}
|
||||
if (resultLine[i] === 0) {
|
||||
return { index: i, state: 2 }; // Suggest Cross
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1 };
|
||||
};
|
||||
|
||||
const isSolved = (grid, solution) => {
|
||||
const size = grid.length;
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const playerCell = grid[r][c];
|
||||
const solutionCell = solution[r][c];
|
||||
const isFilled = playerCell === 1;
|
||||
const shouldBeFilled = solutionCell === 1;
|
||||
|
||||
// Check correctness
|
||||
if (isFilled !== shouldBeFilled) return false;
|
||||
|
||||
// Check completeness (must be fully resolved to 1 or 2)
|
||||
if (playerCell === 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStep = (playerGrid, solution, locale) => {
|
||||
if (isSolved(playerGrid, solution)) {
|
||||
return { type: 'done', statusText: t(locale, 'worker.solved') };
|
||||
}
|
||||
|
||||
const size = solution.length;
|
||||
const { rowHints, colHints } = calculateHints(solution);
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
const rowLine = playerGrid[r];
|
||||
const hints = rowHints[r];
|
||||
const result = solveLineLogic(rowLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateLabel = t(locale, result.state === 1 ? 'worker.state.filled' : 'worker.state.empty');
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c: result.index,
|
||||
state: result.state,
|
||||
statusText: t(locale, 'worker.logicRow', { row: r + 1, col: result.index + 1, state: stateLabel })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let c = 0; c < size; c++) {
|
||||
const colLine = [];
|
||||
for (let r = 0; r < size; r++) colLine.push(playerGrid[r][c]);
|
||||
const hints = colHints[c];
|
||||
const result = solveLineLogic(colLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateLabel = t(locale, result.state === 1 ? 'worker.state.filled' : 'worker.state.empty');
|
||||
return {
|
||||
type: 'move',
|
||||
r: result.index,
|
||||
c,
|
||||
state: result.state,
|
||||
statusText: t(locale, 'worker.logicCol', { row: result.index + 1, col: c + 1, state: stateLabel })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for guess logic - we want to avoid this unless strictly necessary
|
||||
// If no logic move found, return 'stuck' instead of cheating
|
||||
return { type: 'stuck', statusText: t(locale, 'worker.stuck') };
|
||||
};
|
||||
|
||||
const handleBoost = (playerGrid, solution, locale) => {
|
||||
const size = solution.length;
|
||||
// Find first unknown cell and reveal it
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (playerGrid[r][c] === 0) {
|
||||
const correctState = solution[r][c] === 1 ? 1 : 2;
|
||||
const stateLabel = t(locale, correctState === 1 ? 'worker.state.filled' : 'worker.state.empty');
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c,
|
||||
state: correctState,
|
||||
statusText: t(locale, 'worker.boosted', { row: r + 1, col: c + 1, state: stateLabel })
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'done', statusText: t(locale, 'worker.solved') };
|
||||
};
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const { id, playerGrid, solution, locale, action } = event.data;
|
||||
const resolved = resolveLocale(locale);
|
||||
|
||||
if (action === 'boost') {
|
||||
const result = handleBoost(playerGrid, solution, resolved);
|
||||
self.postMessage({ id, ...result });
|
||||
} else {
|
||||
const result = handleStep(playerGrid, solution, resolved);
|
||||
self.postMessage({ id, ...result });
|
||||
}
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const filePath = 'src/composables/useI18n.js';
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 1. Add key to all language objects
|
||||
const lines = content.split('\n');
|
||||
const newLines = [];
|
||||
let insideLang = false;
|
||||
let currentLang = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const langStartMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
|
||||
if (langStartMatch) {
|
||||
insideLang = true;
|
||||
currentLang = langStartMatch[1].replace(/['"]/g, '');
|
||||
}
|
||||
|
||||
if (insideLang && (line.trim() === '},' || line.trim() === '}')) {
|
||||
let translation = 'GUIDE';
|
||||
if (currentLang === 'pl') translation = 'PRZEWODNIK';
|
||||
if (currentLang === 'es') translation = 'GUÍA';
|
||||
if (currentLang === 'fr') translation = 'GUIDE';
|
||||
if (currentLang === 'de') translation = 'ANLEITUNG';
|
||||
if (currentLang === 'it') translation = 'GUIDA';
|
||||
if (currentLang === 'pt' || currentLang === 'pt-br') translation = 'GUIA';
|
||||
if (currentLang === 'ru') translation = 'РУКОВОДСТВО';
|
||||
if (currentLang === 'zh') translation = '指南';
|
||||
|
||||
// Ensure previous line has comma
|
||||
if (newLines.length > 0) {
|
||||
const lastLine = newLines[newLines.length - 1];
|
||||
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
|
||||
newLines[newLines.length - 1] = lastLine + ',';
|
||||
}
|
||||
}
|
||||
|
||||
newLines.push(` 'nav.guide': '${translation}'`);
|
||||
insideLang = false;
|
||||
currentLang = null;
|
||||
}
|
||||
|
||||
newLines.push(line);
|
||||
}
|
||||
|
||||
content = newLines.join('\n');
|
||||
|
||||
// 2. Add to requiredKeys
|
||||
// Find "const requiredKeys = ["
|
||||
// We know it ends with 'nav.newGame' now.
|
||||
content = content.replace(
|
||||
"'nav.newGame'",
|
||||
"'nav.newGame','nav.guide'"
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log('Updated useI18n.js with nav.guide');
|
||||
107
verify_i18n.js
107
verify_i18n.js
@@ -1,107 +0,0 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 1. Parse useI18n.js to get keys
|
||||
const i18nPath = path.join(__dirname, 'src/composables/useI18n.js');
|
||||
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
|
||||
|
||||
function extractKeys(lang) {
|
||||
// Find start of lang block: " en: {"
|
||||
const startRegex = new RegExp(`\\s+${lang}:\\s*\\{`);
|
||||
const startMatch = i18nContent.match(startRegex);
|
||||
if (!startMatch) return new Set();
|
||||
|
||||
const startIndex = startMatch.index + startMatch[0].length;
|
||||
let braceCount = 1;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
let endIndex = -1;
|
||||
|
||||
for (let i = startIndex; i < i18nContent.length; i++) {
|
||||
const char = i18nContent[i];
|
||||
|
||||
if (inString) {
|
||||
if (char === stringChar && i18nContent[i-1] !== '\\') {
|
||||
inString = false;
|
||||
}
|
||||
} else {
|
||||
if (char === "'" || char === '"' || char === '`') {
|
||||
inString = true;
|
||||
stringChar = char;
|
||||
} else if (char === '{') {
|
||||
braceCount++;
|
||||
} else if (char === '}') {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) return new Set();
|
||||
|
||||
const block = i18nContent.substring(startIndex, endIndex);
|
||||
const keys = new Set();
|
||||
const keyRegex = /['"]([\w.-]+)['"]\s*:/g;
|
||||
let match;
|
||||
while ((match = keyRegex.exec(block)) !== null) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const enKeys = extractKeys('en');
|
||||
console.log(`Found ${enKeys.size} keys in en block.`);
|
||||
|
||||
if (enKeys.has('image.title')) {
|
||||
console.log("'image.title' IS present in en block.");
|
||||
} else {
|
||||
console.log("'image.title' is MISSING in en block.");
|
||||
}
|
||||
|
||||
// 2. Scan src for usages
|
||||
try {
|
||||
const grepOutput = execSync(`grep -r "t(['\\"]" src | grep -v "node_modules"`, { encoding: 'utf8' });
|
||||
|
||||
const usedKeys = new Set();
|
||||
const usageRegex = /t\(['"]([\w.-]+)['"]/g;
|
||||
|
||||
const lines = grepOutput.split('\n');
|
||||
for (const line of lines) {
|
||||
let m;
|
||||
while ((m = usageRegex.exec(line)) !== null) {
|
||||
usedKeys.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${usedKeys.size} used keys in src.`);
|
||||
|
||||
// 3. Compare
|
||||
const missingKeys = [];
|
||||
for (const key of usedKeys) {
|
||||
// Skip dynamic keys or composed keys if any (heuristic)
|
||||
if (key.includes('${')) continue;
|
||||
|
||||
if (!enKeys.has(key)) {
|
||||
missingKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
console.log("Missing translations in en:");
|
||||
missingKeys.forEach(k => console.log(` - ${k}`));
|
||||
} else {
|
||||
console.log("No missing translations found in en.");
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error running grep:", e);
|
||||
}
|
||||
Reference in New Issue
Block a user