Compare commits
6 Commits
2261f44b4a
...
v1.14.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
18568fa4ae
|
|||
|
c3188bb740
|
|||
|
43c0290fac
|
|||
|
fa5fa12157
|
|||
|
29682c9a06
|
|||
|
2d30315ae6
|
@@ -82,7 +82,7 @@ define(['./workbox-7a5e81cd'], (function (workbox) { 'use strict';
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "index.html",
|
||||
"revision": "0.geiftdl7j9o"
|
||||
"revision": "0.kkc80cp3p5o"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.12.11",
|
||||
"version": "1.14.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.12.11",
|
||||
"version": "1.14.2",
|
||||
"dependencies": {
|
||||
"fireworks-js": "^2.10.8",
|
||||
"flag-icons": "^7.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.2",
|
||||
"homepage": "https://nonograms.7u.pl/",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
isStuck,
|
||||
speedLabel,
|
||||
statusText,
|
||||
step,
|
||||
togglePlay,
|
||||
changeSpeed
|
||||
changeSpeed,
|
||||
boost
|
||||
} = useSolver();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -29,6 +31,10 @@ const { t } = useI18n();
|
||||
<button class="btn-neon small" @click="changeSpeed">
|
||||
{{ t('guide.speed') }}: {{ speedLabel }}
|
||||
</button>
|
||||
|
||||
<button v-if="isStuck" class="btn-neon small boost-btn" @click="boost">
|
||||
⚡ Boost (DFS)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,4 +74,15 @@ const { t } = useI18n();
|
||||
padding: 5px 15px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.boost-btn {
|
||||
border-color: #ffd700;
|
||||
color: #ffd700;
|
||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.2);
|
||||
}
|
||||
|
||||
.boost-btn:hover {
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,7 @@ const imageLoaded = ref(false);
|
||||
const processing = ref(false);
|
||||
const processingProgress = ref(0);
|
||||
const isCameraOpen = ref(false);
|
||||
const hasMultipleCameras = ref(false);
|
||||
const stream = ref(null);
|
||||
const facingMode = ref('environment');
|
||||
|
||||
@@ -319,6 +320,12 @@ const startCamera = async () => {
|
||||
}
|
||||
};
|
||||
stream.value = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
// Check available devices
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
||||
hasMultipleCameras.value = videoDevices.length > 1;
|
||||
|
||||
// Wait for next tick or ensure videoRef is available
|
||||
setTimeout(() => {
|
||||
if (videoRef.value) {
|
||||
@@ -401,7 +408,7 @@ onUnmounted(() => {
|
||||
<button class="camera-btn capture" @click="capturePhoto">
|
||||
<div class="shutter"></div>
|
||||
</button>
|
||||
<button class="camera-btn secondary" @click="switchCamera">
|
||||
<button v-if="hasMultipleCameras" class="camera-btn secondary" @click="switchCamera">
|
||||
<RefreshCw :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,9 @@ const getShareData = () => ({
|
||||
grid: store.playerGrid,
|
||||
size: store.size,
|
||||
currentDensity: store.currentDensity,
|
||||
guideUsageCount: store.guideUsageCount
|
||||
guideUsageCount: store.guideUsageCount,
|
||||
hasUsedBoost: store.hasUsedBoost,
|
||||
boostUsageCount: store.boostUsageCount
|
||||
});
|
||||
|
||||
const downloadShareSVG = () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ const messages = {
|
||||
'win.shareDownload': 'Pobierz zrzut',
|
||||
'win.difficulty': 'Poziom:',
|
||||
'win.usedGuide': 'Podpowiedzi: {percent}% ({count})',
|
||||
'win.boosted': 'Wspomagany (DFS)',
|
||||
'pwa.installTitle': 'Zainstaluj aplikację i graj offline',
|
||||
'pwa.installMobile': 'Dodaj do ekranu głównego',
|
||||
'pwa.installDesktop': 'Zainstaluj na komputerze',
|
||||
@@ -244,6 +245,7 @@ const messages = {
|
||||
'win.shareDownload': 'Download screenshot',
|
||||
'win.difficulty': 'Difficulty:',
|
||||
'win.usedGuide': 'Hints: {percent}% ({count})',
|
||||
'win.boosted': 'Boosted (DFS)',
|
||||
'pwa.installTitle': 'Install the app and play offline',
|
||||
'pwa.installMobile': 'Add to home screen',
|
||||
'pwa.installDesktop': 'Install on desktop',
|
||||
|
||||
@@ -8,6 +8,7 @@ export function useSolver() {
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const isStuck = ref(false);
|
||||
const speedIndex = ref(0);
|
||||
const speeds = [1000, 500, 250, 125, 62, 31, 16];
|
||||
const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16', 'x32', 'x64'];
|
||||
@@ -25,6 +26,7 @@ export function useSolver() {
|
||||
}
|
||||
if (isProcessing.value) return;
|
||||
store.markGuideUsed();
|
||||
isStuck.value = false;
|
||||
ensureWorker();
|
||||
isProcessing.value = true;
|
||||
|
||||
@@ -62,6 +64,19 @@ export function useSolver() {
|
||||
}
|
||||
}
|
||||
|
||||
function boost() {
|
||||
if (store.isGameWon || isProcessing.value) return;
|
||||
store.markBoostUsed();
|
||||
isStuck.value = false;
|
||||
ensureWorker();
|
||||
isProcessing.value = true;
|
||||
|
||||
const playerGrid = store.playerGrid.map(row => row.slice());
|
||||
const solution = store.solution.map(row => row.slice());
|
||||
const id = ++requestId;
|
||||
worker.postMessage({ id, playerGrid, solution, locale: locale.value, action: 'boost' });
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker) return;
|
||||
worker = new Worker(new URL('../workers/solverWorker.js', import.meta.url), { type: 'module' });
|
||||
@@ -71,15 +86,18 @@ export function useSolver() {
|
||||
if (type === 'move') {
|
||||
store.setCell(r, c, state);
|
||||
isProcessing.value = false;
|
||||
isStuck.value = false;
|
||||
if (store.isGameWon) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
} else if (type === 'done') {
|
||||
isProcessing.value = false;
|
||||
isStuck.value = false;
|
||||
pause();
|
||||
} else if (type === 'stuck') {
|
||||
isProcessing.value = false;
|
||||
isStuck.value = true;
|
||||
pause();
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
@@ -95,11 +113,13 @@ export function useSolver() {
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
isStuck,
|
||||
speedIndex,
|
||||
speedLabel: computed(() => speedLabels[speedIndex.value]),
|
||||
statusText,
|
||||
step,
|
||||
togglePlay,
|
||||
changeSpeed
|
||||
changeSpeed,
|
||||
boost
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
const isGameWon = ref(false);
|
||||
const hasUsedGuide = ref(false);
|
||||
const guideUsageCount = ref(0);
|
||||
const hasUsedBoost = ref(false);
|
||||
const boostUsageCount = ref(0);
|
||||
const currentDifficulty = ref(null); // 'easy', 'medium', 'hard', 'custom' or object { density: 0.5 }
|
||||
const currentDensity = ref(0);
|
||||
const size = ref(5);
|
||||
@@ -72,6 +74,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
hasUsedBoost.value = false;
|
||||
boostUsageCount.value = 0;
|
||||
currentDensity.value = totalCellsToFill.value / (size.value * size.value);
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
@@ -90,6 +94,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
hasUsedBoost.value = false;
|
||||
boostUsageCount.value = 0;
|
||||
currentDensity.value = density;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
@@ -110,6 +116,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
hasUsedBoost.value = false;
|
||||
boostUsageCount.value = 0;
|
||||
|
||||
// Calculate density
|
||||
const totalFilled = grid.flat().filter(c => c === 1).length;
|
||||
@@ -118,13 +126,15 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function resetGrid() {
|
||||
const rows = solution.value.length;
|
||||
const cols = solution.value[0].length;
|
||||
playerGrid.value = Array(rows).fill().map(() => Array(cols).fill(0));
|
||||
history.value = [];
|
||||
moves.value = 0;
|
||||
} currentTransaction.value = null;
|
||||
currentTransaction.value = null;
|
||||
}
|
||||
|
||||
function startInteraction() {
|
||||
@@ -254,6 +264,17 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
// Auto-fill remaining empty cells with X (2)
|
||||
const rows = solution.value.length;
|
||||
const cols = solution.value[0].length;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (playerGrid.value[r][c] === 0) {
|
||||
playerGrid.value[r][c] = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isGameWon.value = true;
|
||||
stopTimer();
|
||||
}
|
||||
@@ -288,6 +309,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
isGameWon: isGameWon.value,
|
||||
hasUsedGuide: hasUsedGuide.value,
|
||||
guideUsageCount: guideUsageCount.value,
|
||||
hasUsedBoost: hasUsedBoost.value,
|
||||
boostUsageCount: boostUsageCount.value,
|
||||
currentDensity: currentDensity.value,
|
||||
elapsedTime: elapsedTime.value,
|
||||
moves: moves.value,
|
||||
@@ -308,6 +331,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
isGameWon.value = parsed.isGameWon;
|
||||
hasUsedGuide.value = parsed.hasUsedGuide || false;
|
||||
guideUsageCount.value = parsed.guideUsageCount || 0;
|
||||
hasUsedBoost.value = parsed.hasUsedBoost || false;
|
||||
boostUsageCount.value = parsed.boostUsageCount || 0;
|
||||
currentDensity.value = parsed.currentDensity || 0;
|
||||
elapsedTime.value = parsed.elapsedTime || 0;
|
||||
moves.value = parsed.moves || 0;
|
||||
@@ -346,6 +371,13 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
saveState();
|
||||
}
|
||||
|
||||
function markBoostUsed() {
|
||||
if (isGameWon.value) return;
|
||||
hasUsedBoost.value = true;
|
||||
boostUsageCount.value++;
|
||||
saveState();
|
||||
}
|
||||
|
||||
function closeWinModal() {
|
||||
if (!isGameWon.value) return;
|
||||
isGameWon.value = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
|
||||
export function buildShareCanvas(data, t, formattedTime) {
|
||||
const { grid, size, currentDensity, guideUsageCount } = data;
|
||||
const { grid, size, currentDensity, guideUsageCount, hasUsedBoost } = data;
|
||||
if (!grid || !grid.length) return null;
|
||||
|
||||
const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
|
||||
@@ -11,7 +11,7 @@ export function buildShareCanvas(data, t, formattedTime) {
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40; // New space for difficulty/guide info
|
||||
const infoHeight = (guideUsageCount > 0 && hasUsedBoost) ? 65 : 40;
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
@@ -97,7 +97,12 @@ export function buildShareCanvas(data, t, formattedTime) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guide Usage Info (Dirty Flag)
|
||||
// Guide Usage & Boost Info
|
||||
let infoY = height - padding - footerHeight + 10;
|
||||
if (guideUsageCount > 0 && hasUsedBoost) {
|
||||
infoY -= 25;
|
||||
}
|
||||
|
||||
if (guideUsageCount > 0) {
|
||||
ctx.fillStyle = '#ff4d4d';
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
@@ -106,7 +111,15 @@ export function buildShareCanvas(data, t, formattedTime) {
|
||||
const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
|
||||
const guideText = t('win.usedGuide', { count: guideUsageCount, percent });
|
||||
|
||||
ctx.fillText(`⚠️ ${guideText}`, padding, height - padding - footerHeight + 10);
|
||||
ctx.fillText(`⚠️ ${guideText}`, padding, infoY);
|
||||
if (hasUsedBoost) infoY += 25;
|
||||
}
|
||||
|
||||
if (hasUsedBoost) {
|
||||
ctx.fillStyle = '#ffd700';
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
const boostText = t('win.boosted');
|
||||
ctx.fillText(`⚡ ${boostText}`, padding, infoY);
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
|
||||
@@ -116,7 +129,7 @@ export function buildShareCanvas(data, t, formattedTime) {
|
||||
}
|
||||
|
||||
export function buildShareSVG(data, t, formattedTime) {
|
||||
const { grid, size, currentDensity, guideUsageCount } = data;
|
||||
const { grid, size, currentDensity, guideUsageCount, hasUsedBoost } = data;
|
||||
if (!grid || !grid.length) return null;
|
||||
|
||||
const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
|
||||
@@ -126,7 +139,7 @@ export function buildShareSVG(data, t, formattedTime) {
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40;
|
||||
const infoHeight = (guideUsageCount > 0 && hasUsedBoost) ? 65 : 40;
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
|
||||
@@ -218,12 +231,23 @@ export function buildShareSVG(data, t, formattedTime) {
|
||||
}
|
||||
svgContent += cells;
|
||||
|
||||
// Guide Usage
|
||||
// Guide Usage & Boost Info
|
||||
let infoY = height - padding - footerHeight + 10;
|
||||
if (guideUsageCount > 0 && hasUsedBoost) {
|
||||
infoY -= 25;
|
||||
}
|
||||
|
||||
if (guideUsageCount > 0) {
|
||||
const totalCells = size * size;
|
||||
const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
|
||||
const guideText = t('win.usedGuide', { count: guideUsageCount, percent });
|
||||
svgContent += `<text x="${padding}" y="${height - padding - footerHeight + 10}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="#ff4d4d">⚠️ ${guideText}</text>`;
|
||||
svgContent += `<text x="${padding}" y="${infoY}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="#ff4d4d">⚠️ ${guideText}</text>`;
|
||||
if (hasUsedBoost) infoY += 25;
|
||||
}
|
||||
|
||||
if (hasUsedBoost) {
|
||||
const boostText = t('win.boosted');
|
||||
svgContent += `<text x="${padding}" y="${infoY}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="#ffd700">⚡ ${boostText}</text>`;
|
||||
}
|
||||
|
||||
// URL
|
||||
|
||||
@@ -18,7 +18,7 @@ const memo = new Map();
|
||||
* @param {number[]} hints - Array of block lengths
|
||||
* @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles)
|
||||
*/
|
||||
function solveLine(currentLine, hints) {
|
||||
export function solveLine(currentLine, hints) {
|
||||
const length = currentLine.length;
|
||||
|
||||
// If no hints, all must be empty
|
||||
@@ -57,6 +57,8 @@ function solveLine(currentLine, hints) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Cannot skip a filled cell - if we pass a '1', it becomes uncovered
|
||||
if (currentLine[currentIdx] === 1) return null;
|
||||
currentIdx++;
|
||||
}
|
||||
if (leftPositions.length <= hIndex) return null; // Impossible
|
||||
@@ -81,6 +83,8 @@ function solveLine(currentLine, hints) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Cannot skip a filled cell
|
||||
if (reversedLine[currentIdx] === 1) return null;
|
||||
currentIdx++;
|
||||
}
|
||||
if (rightPositionsReversed.length <= hIndex) return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { calculateHints } from '../utils/puzzleUtils.js';
|
||||
import { solveLine } from '../utils/solver.js';
|
||||
|
||||
const messages = {
|
||||
pl: {
|
||||
@@ -6,6 +7,7 @@ const messages = {
|
||||
'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'
|
||||
@@ -15,6 +17,7 @@ const messages = {
|
||||
'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'
|
||||
@@ -40,195 +43,33 @@ const t = (locale, key, params) => {
|
||||
return typeof value === 'string' ? format(value, params) : key;
|
||||
};
|
||||
|
||||
const buildPrefix = (lineState) => {
|
||||
const n = lineState.length;
|
||||
const filled = new Array(n + 1).fill(0);
|
||||
const cross = new Array(n + 1).fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
filled[i + 1] = filled[i] + (lineState[i] === 1 ? 1 : 0);
|
||||
cross[i + 1] = cross[i] + (lineState[i] === 2 ? 1 : 0);
|
||||
}
|
||||
return { filled, cross };
|
||||
};
|
||||
|
||||
const buildSuffixMin = (hints) => {
|
||||
const m = hints.length;
|
||||
const suffixMin = new Array(m + 1).fill(0);
|
||||
let sumHints = 0;
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
sumHints += hints[i];
|
||||
const separators = m - i - 1;
|
||||
suffixMin[i] = sumHints + separators;
|
||||
}
|
||||
return suffixMin;
|
||||
};
|
||||
|
||||
const solveLineLogic = (lineState, hints) => {
|
||||
const n = lineState.length;
|
||||
const m = hints.length;
|
||||
if (m === 0) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lineState[i] === 0) return { index: i, state: 2 };
|
||||
// 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
|
||||
}
|
||||
return { index: -1 };
|
||||
}
|
||||
|
||||
const { filled, cross } = buildPrefix(lineState);
|
||||
const suffixMin = buildSuffixMin(hints);
|
||||
|
||||
const hasFilled = (a, b) => filled[b] - filled[a] > 0;
|
||||
const hasCross = (a, b) => cross[b] - cross[a] > 0;
|
||||
|
||||
const memoSuffix = Array.from({ length: n + 1 }, () => Array(m + 1).fill(null));
|
||||
const memoPrefix = Array.from({ length: n + 1 }, () => Array(m + 1).fill(null));
|
||||
|
||||
const canPlaceSuffix = (pos, hintIndex) => {
|
||||
const cached = memoSuffix[pos][hintIndex];
|
||||
if (cached !== null) return cached;
|
||||
if (hintIndex === m) {
|
||||
const result = !hasFilled(pos, n);
|
||||
memoSuffix[pos][hintIndex] = result;
|
||||
return result;
|
||||
}
|
||||
const len = hints[hintIndex];
|
||||
// maxStart logic: we need enough space for this block (len) + subsequent blocks/gaps (suffixMin[hintIndex+1])
|
||||
// suffixMin[hintIndex] = len + (m - hintIndex - 1) + suffixMin[hintIndex+1]
|
||||
// Actually suffixMin[hintIndex] already includes everything needed from here to end.
|
||||
// So if we place block at start, end is start + len.
|
||||
// Total space needed is suffixMin[hintIndex].
|
||||
// So start can go up to n - suffixMin[hintIndex].
|
||||
const maxStart = n - suffixMin[hintIndex];
|
||||
|
||||
for (let start = pos; start <= maxStart; start++) {
|
||||
if (hasFilled(pos, start)) continue; // Must be empty before this block
|
||||
if (hasCross(start, start + len)) continue; // Block space must be free of crosses
|
||||
|
||||
// If not the last block, we need a gap after
|
||||
if (hintIndex < m - 1) {
|
||||
if (start + len < n && lineState[start + len] === 1) continue; // Gap must not be filled
|
||||
// We can assume gap is at start + len. Next block starts at least at start + len + 1
|
||||
const nextPos = start + len + 1;
|
||||
if (canPlaceSuffix(nextPos, hintIndex + 1)) {
|
||||
memoSuffix[pos][hintIndex] = true;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Last block
|
||||
// Check if we can fill the rest with empty
|
||||
if (hasFilled(start + len, n)) continue;
|
||||
memoSuffix[pos][hintIndex] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
memoSuffix[pos][hintIndex] = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const canPlacePrefix = (pos, hintCount) => {
|
||||
const cached = memoPrefix[pos][hintCount];
|
||||
if (cached !== null) return cached;
|
||||
if (hintCount === 0) {
|
||||
const result = !hasFilled(0, pos);
|
||||
memoPrefix[pos][hintCount] = result;
|
||||
return result;
|
||||
}
|
||||
const len = hints[hintCount - 1];
|
||||
|
||||
// Logic for prefix:
|
||||
// We are placing the (hintCount-1)-th block ending at 'start + len' <= pos.
|
||||
// So 'start' <= pos - len.
|
||||
// But we also need to ensure there is space for previous blocks.
|
||||
// However, the simple constraint is just iterating backwards.
|
||||
|
||||
// maxStart: if this is the only block, maxStart = pos - len.
|
||||
// If there are previous blocks, we need a gap before this block.
|
||||
// So previous block ended at start - 1.
|
||||
// Actually the recursive call will handle space check.
|
||||
// But for the gap check:
|
||||
// If we place block at 'start', we need lineState[start-1] != 1 (if start > 0).
|
||||
// And we recursively check canPlacePrefix(start-1, count-1).
|
||||
// But if start=0 and count > 1, impossible.
|
||||
|
||||
const maxStart = pos - len; // Simplified, loop condition handles rest
|
||||
|
||||
for (let start = maxStart; start >= 0; start--) {
|
||||
if (hasCross(start, start + len)) continue;
|
||||
if (hasFilled(start + len, pos)) continue; // Must be empty after this block up to pos
|
||||
|
||||
// Check gap before
|
||||
if (hintCount > 1) {
|
||||
if (start === 0) continue; // No space for previous blocks
|
||||
if (lineState[start - 1] === 1) continue; // Gap must not be filled
|
||||
const prevPos = start - 1;
|
||||
if (canPlacePrefix(prevPos, hintCount - 1)) {
|
||||
memoPrefix[pos][hintCount] = true;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// First block
|
||||
if (hasFilled(0, start)) continue; // Before first block must be empty
|
||||
memoPrefix[pos][hintCount] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
memoPrefix[pos][hintCount] = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const possibleStarts = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const len = hints[i];
|
||||
const starts = [];
|
||||
for (let start = 0; start <= n - len; start++) {
|
||||
if (i === 0) {
|
||||
if (!canPlacePrefix(start, 0)) continue;
|
||||
} else {
|
||||
if (start === 0) continue;
|
||||
if (lineState[start - 1] === 1) continue;
|
||||
if (!canPlacePrefix(start - 1, i)) continue;
|
||||
}
|
||||
|
||||
if (hasCross(start, start + len)) continue;
|
||||
if (start + len < n && lineState[start + len] === 1) continue;
|
||||
const nextPos = start + len < n ? start + len + 1 : start + len;
|
||||
if (!canPlaceSuffix(nextPos, i + 1)) continue;
|
||||
starts.push(start);
|
||||
}
|
||||
possibleStarts.push(starts);
|
||||
}
|
||||
|
||||
const mustFill = new Array(n).fill(false);
|
||||
const coverage = new Array(n).fill(false);
|
||||
|
||||
for (let i = 0; i < m; i++) {
|
||||
const starts = possibleStarts[i];
|
||||
const len = hints[i];
|
||||
if (starts.length === 0) return { index: -1 };
|
||||
let earliest = starts[0];
|
||||
let latest = starts[0];
|
||||
for (let j = 1; j < starts.length; j++) {
|
||||
earliest = Math.min(earliest, starts[j]);
|
||||
latest = Math.max(latest, starts[j]);
|
||||
}
|
||||
const startOverlap = Math.max(earliest, latest);
|
||||
const endOverlap = Math.min(earliest + len - 1, latest + len - 1);
|
||||
for (let k = startOverlap; k <= endOverlap; k++) {
|
||||
if (k >= 0 && k < n) mustFill[k] = true;
|
||||
}
|
||||
for (let s = 0; s < starts.length; s++) {
|
||||
const start = starts[s];
|
||||
for (let k = start; k < start + len; k++) {
|
||||
coverage[k] = true;
|
||||
if (resultLine[i] === 0) {
|
||||
return { index: i, state: 2 }; // Suggest Cross
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lineState[i] === 0 && mustFill[i]) return { index: i, state: 1 };
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lineState[i] === 0 && !coverage[i]) return { index: i, state: 2 };
|
||||
}
|
||||
return { index: -1 };
|
||||
};
|
||||
|
||||
@@ -240,7 +81,12 @@ const isSolved = (grid, solution) => {
|
||||
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;
|
||||
@@ -292,9 +138,36 @@ const handleStep = (playerGrid, solution, locale) => {
|
||||
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 } = event.data;
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user