4 Commits

Author SHA1 Message Date
18568fa4ae 1.14.2
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-13 07:03:18 +01:00
c3188bb740 fix: prevent premature solved status, add DFS boost button, mark boosted exports 2026-02-13 07:03:08 +01:00
43c0290fac 1.14.1
All checks were successful
Deploy to Production / deploy (push) Successful in 18s
2026-02-13 06:14:56 +01:00
fa5fa12157 fix: resetGrid visibility in puzzle store and hide camera switch on single-cam devices 2026-02-13 06:14:51 +01:00
10 changed files with 159 additions and 21 deletions

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "vue-nonograms-solid", "name": "vue-nonograms-solid",
"version": "1.14.0", "version": "1.14.2",
"homepage": "https://nonograms.7u.pl/", "homepage": "https://nonograms.7u.pl/",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -4,11 +4,13 @@ import { useI18n } from '@/composables/useI18n';
const { const {
isPlaying, isPlaying,
isStuck,
speedLabel, speedLabel,
statusText, statusText,
step, step,
togglePlay, togglePlay,
changeSpeed changeSpeed,
boost
} = useSolver(); } = useSolver();
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
@@ -29,6 +31,10 @@ const { t } = useI18n();
<button class="btn-neon small" @click="changeSpeed"> <button class="btn-neon small" @click="changeSpeed">
{{ t('guide.speed') }}: {{ speedLabel }} {{ t('guide.speed') }}: {{ speedLabel }}
</button> </button>
<button v-if="isStuck" class="btn-neon small boost-btn" @click="boost">
Boost (DFS)
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -68,4 +74,15 @@ const { t } = useI18n();
padding: 5px 15px; padding: 5px 15px;
font-size: 0.8rem; 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> </style>

View File

@@ -18,6 +18,7 @@ const imageLoaded = ref(false);
const processing = ref(false); const processing = ref(false);
const processingProgress = ref(0); const processingProgress = ref(0);
const isCameraOpen = ref(false); const isCameraOpen = ref(false);
const hasMultipleCameras = ref(false);
const stream = ref(null); const stream = ref(null);
const facingMode = ref('environment'); const facingMode = ref('environment');
@@ -319,6 +320,12 @@ const startCamera = async () => {
} }
}; };
stream.value = await navigator.mediaDevices.getUserMedia(constraints); 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 // Wait for next tick or ensure videoRef is available
setTimeout(() => { setTimeout(() => {
if (videoRef.value) { if (videoRef.value) {
@@ -401,7 +408,7 @@ onUnmounted(() => {
<button class="camera-btn capture" @click="capturePhoto"> <button class="camera-btn capture" @click="capturePhoto">
<div class="shutter"></div> <div class="shutter"></div>
</button> </button>
<button class="camera-btn secondary" @click="switchCamera"> <button v-if="hasMultipleCameras" class="camera-btn secondary" @click="switchCamera">
<RefreshCw :size="24" /> <RefreshCw :size="24" />
</button> </button>
</div> </div>

View File

@@ -47,7 +47,9 @@ const getShareData = () => ({
grid: store.playerGrid, grid: store.playerGrid,
size: store.size, size: store.size,
currentDensity: store.currentDensity, currentDensity: store.currentDensity,
guideUsageCount: store.guideUsageCount guideUsageCount: store.guideUsageCount,
hasUsedBoost: store.hasUsedBoost,
boostUsageCount: store.boostUsageCount
}); });
const downloadShareSVG = () => { const downloadShareSVG = () => {

View File

@@ -68,6 +68,7 @@ const messages = {
'win.shareDownload': 'Pobierz zrzut', 'win.shareDownload': 'Pobierz zrzut',
'win.difficulty': 'Poziom:', 'win.difficulty': 'Poziom:',
'win.usedGuide': 'Podpowiedzi: {percent}% ({count})', 'win.usedGuide': 'Podpowiedzi: {percent}% ({count})',
'win.boosted': 'Wspomagany (DFS)',
'pwa.installTitle': 'Zainstaluj aplikację i graj offline', 'pwa.installTitle': 'Zainstaluj aplikację i graj offline',
'pwa.installMobile': 'Dodaj do ekranu głównego', 'pwa.installMobile': 'Dodaj do ekranu głównego',
'pwa.installDesktop': 'Zainstaluj na komputerze', 'pwa.installDesktop': 'Zainstaluj na komputerze',
@@ -244,6 +245,7 @@ const messages = {
'win.shareDownload': 'Download screenshot', 'win.shareDownload': 'Download screenshot',
'win.difficulty': 'Difficulty:', 'win.difficulty': 'Difficulty:',
'win.usedGuide': 'Hints: {percent}% ({count})', 'win.usedGuide': 'Hints: {percent}% ({count})',
'win.boosted': 'Boosted (DFS)',
'pwa.installTitle': 'Install the app and play offline', 'pwa.installTitle': 'Install the app and play offline',
'pwa.installMobile': 'Add to home screen', 'pwa.installMobile': 'Add to home screen',
'pwa.installDesktop': 'Install on desktop', 'pwa.installDesktop': 'Install on desktop',

View File

@@ -8,6 +8,7 @@ export function useSolver() {
const isPlaying = ref(false); const isPlaying = ref(false);
const isProcessing = ref(false); const isProcessing = ref(false);
const isStuck = ref(false);
const speedIndex = ref(0); const speedIndex = ref(0);
const speeds = [1000, 500, 250, 125, 62, 31, 16]; const speeds = [1000, 500, 250, 125, 62, 31, 16];
const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16', 'x32', 'x64']; const speedLabels = ['x1', 'x2', 'x4', 'x8', 'x16', 'x32', 'x64'];
@@ -25,6 +26,7 @@ export function useSolver() {
} }
if (isProcessing.value) return; if (isProcessing.value) return;
store.markGuideUsed(); store.markGuideUsed();
isStuck.value = false;
ensureWorker(); ensureWorker();
isProcessing.value = true; 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() { function ensureWorker() {
if (worker) return; if (worker) return;
worker = new Worker(new URL('../workers/solverWorker.js', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../workers/solverWorker.js', import.meta.url), { type: 'module' });
@@ -71,15 +86,18 @@ export function useSolver() {
if (type === 'move') { if (type === 'move') {
store.setCell(r, c, state); store.setCell(r, c, state);
isProcessing.value = false; isProcessing.value = false;
isStuck.value = false;
if (store.isGameWon) { if (store.isGameWon) {
pause(); pause();
return; return;
} }
} else if (type === 'done') { } else if (type === 'done') {
isProcessing.value = false; isProcessing.value = false;
isStuck.value = false;
pause(); pause();
} else if (type === 'stuck') { } else if (type === 'stuck') {
isProcessing.value = false; isProcessing.value = false;
isStuck.value = true;
pause(); pause();
} else { } else {
isProcessing.value = false; isProcessing.value = false;
@@ -95,11 +113,13 @@ export function useSolver() {
return { return {
isPlaying, isPlaying,
isStuck,
speedIndex, speedIndex,
speedLabel: computed(() => speedLabels[speedIndex.value]), speedLabel: computed(() => speedLabels[speedIndex.value]),
statusText, statusText,
step, step,
togglePlay, togglePlay,
changeSpeed changeSpeed,
boost
}; };
} }

View File

@@ -11,6 +11,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
const isGameWon = ref(false); const isGameWon = ref(false);
const hasUsedGuide = ref(false); const hasUsedGuide = ref(false);
const guideUsageCount = ref(0); 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 currentDifficulty = ref(null); // 'easy', 'medium', 'hard', 'custom' or object { density: 0.5 }
const currentDensity = ref(0); const currentDensity = ref(0);
const size = ref(5); const size = ref(5);
@@ -72,6 +74,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false; hasUsedGuide.value = false;
guideUsageCount.value = 0; guideUsageCount.value = 0;
hasUsedBoost.value = false;
boostUsageCount.value = 0;
currentDensity.value = totalCellsToFill.value / (size.value * size.value); currentDensity.value = totalCellsToFill.value / (size.value * size.value);
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
@@ -90,6 +94,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false; hasUsedGuide.value = false;
guideUsageCount.value = 0; guideUsageCount.value = 0;
hasUsedBoost.value = false;
boostUsageCount.value = 0;
currentDensity.value = density; currentDensity.value = density;
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
@@ -110,6 +116,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
isGameWon.value = false; isGameWon.value = false;
hasUsedGuide.value = false; hasUsedGuide.value = false;
guideUsageCount.value = 0; guideUsageCount.value = 0;
hasUsedBoost.value = false;
boostUsageCount.value = 0;
// Calculate density // Calculate density
const totalFilled = grid.flat().filter(c => c === 1).length; const totalFilled = grid.flat().filter(c => c === 1).length;
@@ -118,13 +126,15 @@ export const usePuzzleStore = defineStore('puzzle', () => {
elapsedTime.value = 0; elapsedTime.value = 0;
startTimer(); startTimer();
saveState(); saveState();
}
function resetGrid() { function resetGrid() {
const rows = solution.value.length; const rows = solution.value.length;
const cols = solution.value[0].length; const cols = solution.value[0].length;
playerGrid.value = Array(rows).fill().map(() => Array(cols).fill(0)); playerGrid.value = Array(rows).fill().map(() => Array(cols).fill(0));
history.value = []; history.value = [];
moves.value = 0; moves.value = 0;
} currentTransaction.value = null; currentTransaction.value = null;
} }
function startInteraction() { function startInteraction() {
@@ -254,6 +264,17 @@ export const usePuzzleStore = defineStore('puzzle', () => {
} }
if (correct) { 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; isGameWon.value = true;
stopTimer(); stopTimer();
} }
@@ -288,6 +309,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
isGameWon: isGameWon.value, isGameWon: isGameWon.value,
hasUsedGuide: hasUsedGuide.value, hasUsedGuide: hasUsedGuide.value,
guideUsageCount: guideUsageCount.value, guideUsageCount: guideUsageCount.value,
hasUsedBoost: hasUsedBoost.value,
boostUsageCount: boostUsageCount.value,
currentDensity: currentDensity.value, currentDensity: currentDensity.value,
elapsedTime: elapsedTime.value, elapsedTime: elapsedTime.value,
moves: moves.value, moves: moves.value,
@@ -308,6 +331,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
isGameWon.value = parsed.isGameWon; isGameWon.value = parsed.isGameWon;
hasUsedGuide.value = parsed.hasUsedGuide || false; hasUsedGuide.value = parsed.hasUsedGuide || false;
guideUsageCount.value = parsed.guideUsageCount || 0; guideUsageCount.value = parsed.guideUsageCount || 0;
hasUsedBoost.value = parsed.hasUsedBoost || false;
boostUsageCount.value = parsed.boostUsageCount || 0;
currentDensity.value = parsed.currentDensity || 0; currentDensity.value = parsed.currentDensity || 0;
elapsedTime.value = parsed.elapsedTime || 0; elapsedTime.value = parsed.elapsedTime || 0;
moves.value = parsed.moves || 0; moves.value = parsed.moves || 0;
@@ -346,6 +371,13 @@ export const usePuzzleStore = defineStore('puzzle', () => {
saveState(); saveState();
} }
function markBoostUsed() {
if (isGameWon.value) return;
hasUsedBoost.value = true;
boostUsageCount.value++;
saveState();
}
function closeWinModal() { function closeWinModal() {
if (!isGameWon.value) return; if (!isGameWon.value) return;
isGameWon.value = false; isGameWon.value = false;

View File

@@ -1,7 +1,7 @@
import { calculateDifficulty } from '@/utils/puzzleUtils'; import { calculateDifficulty } from '@/utils/puzzleUtils';
export function buildShareCanvas(data, t, formattedTime) { 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; if (!grid || !grid.length) return null;
const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : ''; const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
@@ -11,7 +11,7 @@ export function buildShareCanvas(data, t, formattedTime) {
const padding = 28; const padding = 28;
const headerHeight = 64; const headerHeight = 64;
const footerHeight = 28; const footerHeight = 28;
const infoHeight = 40; // New space for difficulty/guide info const infoHeight = (guideUsageCount > 0 && hasUsedBoost) ? 65 : 40;
const width = boardSize + padding * 2; const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight; const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
const scale = window.devicePixelRatio || 1; 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) { if (guideUsageCount > 0) {
ctx.fillStyle = '#ff4d4d'; ctx.fillStyle = '#ff4d4d';
ctx.font = '600 14px "Segoe UI", sans-serif'; 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 percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: guideUsageCount, percent }); 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)'; ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
@@ -116,7 +129,7 @@ export function buildShareCanvas(data, t, formattedTime) {
} }
export function buildShareSVG(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; if (!grid || !grid.length) return null;
const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : ''; const appUrl = typeof __APP_HOMEPAGE__ !== 'undefined' ? __APP_HOMEPAGE__ : '';
@@ -126,7 +139,7 @@ export function buildShareSVG(data, t, formattedTime) {
const padding = 28; const padding = 28;
const headerHeight = 64; const headerHeight = 64;
const footerHeight = 28; const footerHeight = 28;
const infoHeight = 40; const infoHeight = (guideUsageCount > 0 && hasUsedBoost) ? 65 : 40;
const width = boardSize + padding * 2; const width = boardSize + padding * 2;
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight; const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
@@ -218,12 +231,23 @@ export function buildShareSVG(data, t, formattedTime) {
} }
svgContent += cells; svgContent += cells;
// Guide Usage // Guide Usage & Boost Info
let infoY = height - padding - footerHeight + 10;
if (guideUsageCount > 0 && hasUsedBoost) {
infoY -= 25;
}
if (guideUsageCount > 0) { if (guideUsageCount > 0) {
const totalCells = size * size; const totalCells = size * size;
const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100)); const percent = Math.min(100, Math.round((guideUsageCount / totalCells) * 100));
const guideText = t('win.usedGuide', { count: guideUsageCount, percent }); 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 // URL

View File

@@ -7,6 +7,7 @@ const messages = {
'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}', 'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}',
'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}', 'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}',
'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.', 'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.',
'worker.boosted': 'Boost (DFS): Wiersz {row}, Kolumna {col} -> {state}',
'worker.done': 'Koniec!', 'worker.done': 'Koniec!',
'worker.state.filled': 'Pełne', 'worker.state.filled': 'Pełne',
'worker.state.empty': 'Puste' 'worker.state.empty': 'Puste'
@@ -16,6 +17,7 @@ const messages = {
'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}', 'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}',
'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}', 'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}',
'worker.stuck': 'No logical move found. Try guessing or undoing.', 'worker.stuck': 'No logical move found. Try guessing or undoing.',
'worker.boosted': 'Boost (DFS): Row {row}, Column {col} -> {state}',
'worker.done': 'Done!', 'worker.done': 'Done!',
'worker.state.filled': 'Filled', 'worker.state.filled': 'Filled',
'worker.state.empty': 'Empty' 'worker.state.empty': 'Empty'
@@ -79,7 +81,12 @@ const isSolved = (grid, solution) => {
const solutionCell = solution[r][c]; const solutionCell = solution[r][c];
const isFilled = playerCell === 1; const isFilled = playerCell === 1;
const shouldBeFilled = solutionCell === 1; const shouldBeFilled = solutionCell === 1;
// Check correctness
if (isFilled !== shouldBeFilled) return false; if (isFilled !== shouldBeFilled) return false;
// Check completeness (must be fully resolved to 1 or 2)
if (playerCell === 0) return false;
} }
} }
return true; return true;
@@ -131,9 +138,36 @@ const handleStep = (playerGrid, solution, locale) => {
return { type: 'stuck', statusText: t(locale, 'worker.stuck') }; return { type: 'stuck', statusText: t(locale, 'worker.stuck') };
}; };
self.onmessage = (event) => { const handleBoost = (playerGrid, solution, locale) => {
const { id, playerGrid, solution, locale } = event.data; const size = solution.length;
const resolved = resolveLocale(locale); // Find first unknown cell and reveal it
const result = handleStep(playerGrid, solution, resolved); for (let r = 0; r < size; r++) {
self.postMessage({ id, ...result }); 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 });
}
}; };