Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca58b68d7c | |||
| 6854fe39e5 | |||
| d68c4a6c3d | |||
| 23c9137985 | |||
| c3ddadeb17 | |||
| 36b0c18370 | |||
| 943c045a65 | |||
| 27a1ce38fb |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.5",
|
||||
"dependencies": {
|
||||
"fireworks-js": "^2.10.8",
|
||||
"flag-icons": "^7.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -190,6 +190,7 @@ onUnmounted(() => {
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
padding-bottom: 50px;
|
||||
padding-top: 100px; /* Space for fixed NavBar */
|
||||
}
|
||||
|
||||
.install-banner {
|
||||
@@ -204,6 +205,9 @@ onUnmounted(() => {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 20px auto; /* Center it manually */
|
||||
box-shadow: var(--banner-shadow);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.install-text {
|
||||
@@ -239,6 +243,9 @@ onUnmounted(() => {
|
||||
margin-right: auto;
|
||||
max-width: 1200px; /* Keep constraint for panels */
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
import { formatTime } from '@/utils/timeUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { formatTime } = useTimer();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isVisible = ref(false);
|
||||
|
||||
@@ -435,8 +435,10 @@ watch(isMobileMenuOpen, (val) => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
import { formatTime } from '@/utils/timeUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { RotateCcw, RefreshCw, Eye, Undo } from 'lucide-vue-next';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { formatTime } = useTimer();
|
||||
const { t } = useI18n();
|
||||
|
||||
const formattedTime = computed(() => formatTime(store.elapsedTime));
|
||||
|
||||
@@ -3,17 +3,20 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Fireworks } from 'fireworks-js';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
import { formatTime } from '@/utils/timeUtils';
|
||||
import { playFanfare, cleanupAudio } from '@/utils/audio';
|
||||
import {
|
||||
buildShareUrl,
|
||||
downloadShareSVG as utilsDownloadShareSVG,
|
||||
downloadShareImage as utilsDownloadShareImage,
|
||||
createShareBlob
|
||||
} from '@/utils/shareUtils';
|
||||
import { Download, FileCode } from 'lucide-vue-next';
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { t } = useI18n();
|
||||
const { formatTime } = useTimer();
|
||||
const fireworksRef = ref(null);
|
||||
let fireworksInstance = null;
|
||||
let audioContext = null;
|
||||
let masterGain = null;
|
||||
const shareInProgress = ref(false);
|
||||
|
||||
const formattedTime = computed(() => formatTime(store.elapsedTime));
|
||||
@@ -29,57 +32,6 @@ const handleKeyDown = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const playFanfare = async () => {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
audioContext = new AudioCtx();
|
||||
if (audioContext.state === 'suspended') {
|
||||
try {
|
||||
await audioContext.resume();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
masterGain = audioContext.createGain();
|
||||
masterGain.gain.value = 0.25; // Slightly louder but softer tone
|
||||
masterGain.connect(audioContext.destination);
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
const playNote = (freq, startTime, duration) => {
|
||||
const osc = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
|
||||
// Mix of sine and triangle for a bell-like quality
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Envelope for elegant bell/chime sound
|
||||
gain.gain.setValueAtTime(0, startTime);
|
||||
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Soft attack
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Long release
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + duration + 0.1);
|
||||
};
|
||||
|
||||
// C Major 7 Arpeggio sequence (C5, E5, G5, B5, C6) - Elegant & Uplifting
|
||||
const sequence = [
|
||||
{ freq: 523.25, time: 0.0, dur: 0.8 }, // C5
|
||||
{ freq: 659.25, time: 0.1, dur: 0.8 }, // E5
|
||||
{ freq: 783.99, time: 0.2, dur: 0.8 }, // G5
|
||||
{ freq: 987.77, time: 0.3, dur: 0.8 }, // B5 (Maj7)
|
||||
{ freq: 1046.50, time: 0.4, dur: 2.0 }, // C6 (High C resolve)
|
||||
// Add a bass root note at the end for fullness
|
||||
{ freq: 523.25, time: 0.4, dur: 2.0 } // C5
|
||||
];
|
||||
|
||||
sequence.forEach(note => playNote(note.freq, now + note.time, note.dur));
|
||||
};
|
||||
|
||||
const triggerVibration = () => {
|
||||
if (!('vibrate' in navigator)) return;
|
||||
const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches;
|
||||
@@ -89,286 +41,19 @@ const triggerVibration = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildShareCanvas = () => {
|
||||
const grid = store.playerGrid;
|
||||
if (!grid || !grid.length) return null;
|
||||
const appUrl = 'https://nonograms.7u.pl/';
|
||||
const size = store.size;
|
||||
const maxBoard = 640;
|
||||
const cellSize = Math.max(8, Math.floor(maxBoard / size));
|
||||
const boardSize = cellSize * size;
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40; // New space for difficulty/guide info
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.scale(scale, scale);
|
||||
const bg = ctx.createLinearGradient(0, 0, width, height);
|
||||
bg.addColorStop(0, '#1b2a4a');
|
||||
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';
|
||||
ctx.fillText(t('app.title'), padding, padding + 10);
|
||||
ctx.font = '600 16px "Segoe UI", sans-serif';
|
||||
ctx.fillText(`${t('win.time')} ${formattedTime.value}`, padding, padding + 34);
|
||||
|
||||
// Difficulty & Density Info
|
||||
const densityPercent = Math.round(store.currentDensity * 100);
|
||||
const difficultyKey = calculateDifficulty(store.currentDensity);
|
||||
let diffColor = '#33ff33';
|
||||
if (difficultyKey === 'extreme') diffColor = '#ff3333';
|
||||
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
|
||||
else if (difficultyKey === 'harder') diffColor = '#ffff33';
|
||||
|
||||
const difficultyText = t(`difficulty.${difficultyKey}`);
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
|
||||
// Right aligned difficulty info
|
||||
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
|
||||
const diffWidth = ctx.measureText(diffLabel).width;
|
||||
ctx.fillStyle = diffColor;
|
||||
ctx.fillText(diffLabel, width - padding - diffWidth, padding + 34);
|
||||
|
||||
const gridX = padding;
|
||||
const gridY = padding + headerHeight;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.fillRect(gridX, gridY, boardSize, boardSize);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= size; i++) {
|
||||
const x = gridX + i * cellSize;
|
||||
const y = gridY + i * cellSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, gridY);
|
||||
ctx.lineTo(x, gridY + boardSize);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gridX, y);
|
||||
ctx.lineTo(gridX + boardSize, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#00f2fe';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const state = grid[r]?.[c];
|
||||
if (state === 1) {
|
||||
const x = gridX + c * cellSize + 1;
|
||||
const y = gridY + r * cellSize + 1;
|
||||
ctx.fillRect(x, y, cellSize - 2, cellSize - 2);
|
||||
} else if (state === 2) {
|
||||
const x = gridX + c * cellSize + cellSize * 0.2;
|
||||
const y = gridY + r * cellSize + cellSize * 0.2;
|
||||
const d = cellSize * 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + d, y + d);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + d, y);
|
||||
ctx.lineTo(x, y + d);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guide Usage Info (Dirty Flag)
|
||||
if (store.guideUsageCount > 0) {
|
||||
ctx.fillStyle = '#ff4d4d';
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
|
||||
const totalCells = store.size * store.size;
|
||||
const percent = Math.min(100, Math.round((store.guideUsageCount / totalCells) * 100));
|
||||
const guideText = t('win.usedGuide', { count: store.guideUsageCount, percent });
|
||||
|
||||
ctx.fillText(`⚠️ ${guideText}`, padding, height - padding - footerHeight + 10);
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
|
||||
ctx.font = '500 14px "Segoe UI", sans-serif';
|
||||
ctx.fillText(appUrl, padding, height - padding + 6);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const buildShareSVG = () => {
|
||||
const grid = store.playerGrid;
|
||||
if (!grid || !grid.length) return null;
|
||||
|
||||
const appUrl = 'https://nonograms.7u.pl/';
|
||||
const size = store.size;
|
||||
const maxBoard = 640;
|
||||
const cellSize = Math.max(8, Math.floor(maxBoard / size));
|
||||
const boardSize = cellSize * size;
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40;
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
|
||||
// Colors
|
||||
const bgGradientStart = '#1b2a4a';
|
||||
const bgGradientEnd = '#0a1324';
|
||||
const overlayColor = 'rgba(0, 0, 0, 0.35)';
|
||||
const textColor = '#e8fbff';
|
||||
const gridColor = 'rgba(255, 255, 255, 0.06)';
|
||||
const gridLineColor = 'rgba(255, 255, 255, 0.12)';
|
||||
const filledColor = '#00f2fe';
|
||||
const crossColor = 'rgba(255, 255, 255, 0.5)';
|
||||
const urlColor = 'rgba(255, 255, 255, 0.75)';
|
||||
|
||||
// Difficulty Logic
|
||||
const densityPercent = Math.round(store.currentDensity * 100);
|
||||
const diffInfo = calculateDifficulty(store.currentDensity, store.size);
|
||||
const difficultyKey = diffInfo.level;
|
||||
let diffColor = '#33ff33';
|
||||
if (difficultyKey === 'extreme') diffColor = '#ff3333';
|
||||
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
|
||||
else if (difficultyKey === 'harder') diffColor = '#ffff33';
|
||||
const difficultyText = t(`difficulty.${difficultyKey}`);
|
||||
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
|
||||
|
||||
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`;
|
||||
|
||||
// Background
|
||||
svgContent += `
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${bgGradientStart}"/>
|
||||
<stop offset="100%" stop-color="${bgGradientEnd}"/>
|
||||
</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
|
||||
svgContent += `
|
||||
<text x="${padding}" y="${padding + 28}" font-family="Segoe UI, sans-serif" font-weight="700" font-size="26" fill="${textColor}">${t('app.title')}</text>
|
||||
<text x="${padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="16" fill="${textColor}">${t('win.time')} ${formattedTime.value}</text>
|
||||
`;
|
||||
|
||||
// Text: Difficulty (Right Aligned - manual approx or end anchor)
|
||||
svgContent += `
|
||||
<text x="${width - padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="${diffColor}" text-anchor="end">${diffLabel}</text>
|
||||
`;
|
||||
|
||||
const gridX = padding;
|
||||
const gridY = padding + headerHeight;
|
||||
|
||||
// Grid Background
|
||||
svgContent += `<rect x="${gridX}" y="${gridY}" width="${boardSize}" height="${boardSize}" fill="${gridColor}"/>`;
|
||||
|
||||
// Grid Lines
|
||||
let gridLines = '';
|
||||
for (let i = 0; i <= size; i++) {
|
||||
const pos = i * cellSize;
|
||||
// Vertical
|
||||
gridLines += `<line x1="${gridX + pos}" y1="${gridY}" x2="${gridX + pos}" y2="${gridY + boardSize}" stroke="${gridLineColor}" stroke-width="1"/>`;
|
||||
// Horizontal
|
||||
gridLines += `<line x1="${gridX}" y1="${gridY + pos}" x2="${gridX + boardSize}" y2="${gridY + pos}" stroke="${gridLineColor}" stroke-width="1"/>`;
|
||||
}
|
||||
svgContent += gridLines;
|
||||
|
||||
// Cells
|
||||
let cells = '';
|
||||
const lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const state = grid[r]?.[c];
|
||||
const cx = gridX + c * cellSize;
|
||||
const cy = gridY + r * cellSize;
|
||||
|
||||
if (state === 1) { // Filled
|
||||
cells += `<rect x="${cx + 1}" y="${cy + 1}" width="${cellSize - 2}" height="${cellSize - 2}" fill="${filledColor}"/>`;
|
||||
} else if (state === 2) { // Cross
|
||||
const d = cellSize * 0.6;
|
||||
const off = cellSize * 0.2;
|
||||
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}"
|
||||
stroke="${crossColor}" stroke-width="${lineWidth}" stroke-linecap="round"/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
svgContent += cells;
|
||||
|
||||
// Guide Usage
|
||||
if (store.guideUsageCount > 0) {
|
||||
const totalCells = store.size * store.size;
|
||||
const percent = Math.min(100, Math.round((store.guideUsageCount / totalCells) * 100));
|
||||
const guideText = t('win.usedGuide', { count: store.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>`;
|
||||
}
|
||||
|
||||
// URL
|
||||
svgContent += `<text x="${padding}" y="${height - padding + 6}" font-family="Segoe UI, sans-serif" font-weight="500" font-size="14" fill="${urlColor}">${appUrl}</text>`;
|
||||
|
||||
svgContent += '</svg>';
|
||||
return svgContent;
|
||||
};
|
||||
|
||||
const canvasToBlob = (canvas) => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png'));
|
||||
|
||||
const createShareBlob = async () => {
|
||||
const canvas = buildShareCanvas();
|
||||
if (!canvas) return null;
|
||||
return canvasToBlob(canvas);
|
||||
};
|
||||
const getShareData = () => ({
|
||||
grid: store.playerGrid,
|
||||
size: store.size,
|
||||
currentDensity: store.currentDensity,
|
||||
guideUsageCount: store.guideUsageCount
|
||||
});
|
||||
|
||||
const downloadShareSVG = () => {
|
||||
const svgString = buildShareSVG();
|
||||
if (!svgString) return;
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nonogram-${store.size}x${store.size}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
utilsDownloadShareSVG(getShareData(), t, formattedTime.value);
|
||||
};
|
||||
|
||||
const downloadShareImage = async () => {
|
||||
const blob = await createShareBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nonogram-${store.size}x${store.size}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const buildShareUrl = (target, text, url) => {
|
||||
const encodedText = encodeURIComponent(text);
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
if (target === 'x') {
|
||||
return `https://x.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
|
||||
}
|
||||
if (target === 'facebook') {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}"e=${encodedText}`;
|
||||
}
|
||||
if (target === 'whatsapp') {
|
||||
return `https://wa.me/?text=${encodeURIComponent(`${text} ${url}`)}`;
|
||||
}
|
||||
return '';
|
||||
await utilsDownloadShareImage(getShareData(), t, formattedTime.value);
|
||||
};
|
||||
|
||||
const shareTo = async (target) => {
|
||||
@@ -382,7 +67,7 @@ const shareTo = async (target) => {
|
||||
try {
|
||||
// Try native share first if available (supports images)
|
||||
if (navigator.share && navigator.canShare) {
|
||||
const blob = await createShareBlob();
|
||||
const blob = await createShareBlob(getShareData(), t, formattedTime.value);
|
||||
if (blob) {
|
||||
const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' });
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
@@ -406,10 +91,6 @@ const shareTo = async (target) => {
|
||||
}
|
||||
|
||||
// Fallback: Direct Link + Download
|
||||
// Open window immediately if possible (though we awaited above, so it might be blocked,
|
||||
// but we can't do much about it if we want to try native share first).
|
||||
// Ideally, for Desktop, navigator.share is undefined so we skip the await above.
|
||||
|
||||
if (shareUrl) {
|
||||
window.open(shareUrl, '_blank', 'noopener');
|
||||
}
|
||||
@@ -450,11 +131,7 @@ onUnmounted(() => {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(0);
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
masterGain = null;
|
||||
cleanupAudio();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -472,27 +149,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share">
|
||||
<div class="share-title">{{ t('win.shareTitle') }}</div>
|
||||
<div class="share-buttons">
|
||||
<div class="social-section">
|
||||
<div class="social-header">{{ t('win.shareTitle') }}</div>
|
||||
<div class="social-grid">
|
||||
<!-- X (Twitter) -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M18.901 3H22l-7.21 8.26L23 21h-6.66L11.13 14.76 5.66 21H2.56l7.73-8.83L1 3h6.8l4.63 5.56L18.9 3h.001zm-1.2 15.9h1.77L6.44 5.1H4.44l13.26 13.8z"/></svg>
|
||||
<button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareX')" @click="shareTo('x')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M18.901 3H22l-7.21 8.26L23 21h-6.66L11.13 14.76 5.66 21H2.56l7.73-8.83L1 3h6.8l4.63 5.56L18.9 3h.001zm-1.2 15.9h1.77L6.44 5.1H4.44l13.26 13.8z"/></svg>
|
||||
</button>
|
||||
<!-- Facebook -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.791-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
<button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareFacebook')" @click="shareTo('facebook')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.791-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</button>
|
||||
<!-- WhatsApp -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="share-icon"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
<button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareWhatsapp')" @click="shareTo('whatsapp')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="social-icon"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.008-.57-.008-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
</button>
|
||||
<!-- Download Screenshot (Compact) -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage">
|
||||
<button class="btn-neon secondary social-item" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage">
|
||||
<Download :size="20" />
|
||||
</button>
|
||||
<!-- Download SVG (Compact) -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" aria-label="Download SVG" @click="downloadShareSVG">
|
||||
<button class="btn-neon secondary social-item" :disabled="shareInProgress" aria-label="Download SVG" @click="downloadShareSVG">
|
||||
<FileCode :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -575,14 +252,14 @@ p {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.share {
|
||||
.social-section {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
.social-header {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
@@ -590,14 +267,14 @@ p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
.social-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
.social-item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
@@ -607,14 +284,14 @@ p {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.share-icon {
|
||||
.social-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.share-download {
|
||||
.social-download {
|
||||
align-self: center;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
export function useTimer() {
|
||||
const time = ref(0);
|
||||
const timerInterval = ref(null);
|
||||
const isRunning = ref(false);
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (isRunning.value) return;
|
||||
isRunning.value = true;
|
||||
const startTime = Date.now() - (time.value * 1000);
|
||||
|
||||
timerInterval.value = setInterval(() => {
|
||||
time.value = Math.floor((Date.now() - startTime) / 1000);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value);
|
||||
timerInterval.value = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
stop();
|
||||
time.value = 0;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
return {
|
||||
time,
|
||||
isRunning,
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
formatTime
|
||||
};
|
||||
}
|
||||
53
src/constants/puzzles.js
Normal file
53
src/constants/puzzles.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const PUZZLES = {
|
||||
easy: {
|
||||
id: 'easy',
|
||||
name: 'Uśmiech',
|
||||
size: 5,
|
||||
grid: [
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 0, 0, 0, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
medium: {
|
||||
id: 'medium',
|
||||
name: 'Domek',
|
||||
size: 10,
|
||||
grid: [
|
||||
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
hard: {
|
||||
id: 'hard',
|
||||
name: 'Statek',
|
||||
size: 15,
|
||||
grid: [
|
||||
[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0],
|
||||
[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0]
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -1,61 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { generateRandomGrid } from '@/utils/puzzleUtils';
|
||||
|
||||
// Definicje zagadek (Static Puzzles)
|
||||
const PUZZLES = {
|
||||
easy: {
|
||||
id: 'easy',
|
||||
name: 'Uśmiech',
|
||||
size: 5,
|
||||
grid: [
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 0, 0, 0, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
medium: {
|
||||
id: 'medium',
|
||||
name: 'Domek',
|
||||
size: 10,
|
||||
grid: [
|
||||
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
hard: {
|
||||
id: 'hard',
|
||||
name: 'Statek',
|
||||
size: 15,
|
||||
grid: [
|
||||
[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0],
|
||||
[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0]
|
||||
]
|
||||
}
|
||||
};
|
||||
import { generateRandomGrid, calculateHints, calculateLineHints, validateLine } from '@/utils/puzzleUtils';
|
||||
import { PUZZLES } from '@/constants/puzzles';
|
||||
|
||||
export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
// State
|
||||
@@ -249,20 +195,32 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
|
||||
function checkWin() {
|
||||
let correct = true;
|
||||
|
||||
// Calculate expected hints from solution (truth)
|
||||
// We do this dynamically to ensure we always check against the rules of the board
|
||||
const solutionRows = solution.value;
|
||||
const solutionCols = Array(size.value).fill().map((_, c) => solution.value.map(r => r[c]));
|
||||
|
||||
// Check Rows
|
||||
for (let r = 0; r < size.value; r++) {
|
||||
const targetHints = calculateLineHints(solutionRows[r]);
|
||||
const playerLine = playerGrid.value[r];
|
||||
if (!validateLine(playerLine, targetHints)) {
|
||||
correct = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
// Check Columns
|
||||
for (let c = 0; c < size.value; c++) {
|
||||
const playerCell = playerGrid.value[r][c];
|
||||
const solutionCell = solution.value[r][c];
|
||||
|
||||
const isFilled = playerCell === 1;
|
||||
const shouldBeFilled = solutionCell === 1;
|
||||
|
||||
if (isFilled !== shouldBeFilled) {
|
||||
const targetHints = calculateLineHints(solutionCols[c]);
|
||||
const playerLine = playerGrid.value.map(row => row[c]);
|
||||
if (!validateLine(playerLine, targetHints)) {
|
||||
correct = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!correct) break;
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
@@ -337,12 +295,6 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Duplicate initGame removed
|
||||
|
||||
// Duplicate initCustomGame removed
|
||||
|
||||
// Duplicate toggleCell/setCell removed
|
||||
|
||||
function resetGame() {
|
||||
if (currentLevelId.value === 'custom') {
|
||||
resetGrid();
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -139,18 +138,18 @@ body {
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-gradient);
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure no other content is visible */
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
72
src/utils/audio.js
Normal file
72
src/utils/audio.js
Normal file
@@ -0,0 +1,72 @@
|
||||
let audioContext = null;
|
||||
let masterGain = null;
|
||||
|
||||
export async function playFanfare() {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioCtx();
|
||||
}
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
try {
|
||||
await audioContext.resume();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-create gain if needed or just use it.
|
||||
// In the original code, it was created every time playFanfare was called?
|
||||
// Original: masterGain = audioContext.createGain();
|
||||
// It's better to create it once or manage it properly.
|
||||
// Let's stick to the original logic but encapsulated.
|
||||
|
||||
masterGain = audioContext.createGain();
|
||||
masterGain.gain.value = 0.25; // Slightly louder but softer tone
|
||||
masterGain.connect(audioContext.destination);
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
const playNote = (freq, startTime, duration) => {
|
||||
const osc = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
|
||||
// Mix of sine and triangle for a bell-like quality
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Envelope for elegant bell/chime sound
|
||||
gain.gain.setValueAtTime(0, startTime);
|
||||
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Soft attack
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Long release
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + duration + 0.1);
|
||||
};
|
||||
|
||||
// C Major 7 Arpeggio sequence (C5, E5, G5, B5, C6) - Elegant & Uplifting
|
||||
const sequence = [
|
||||
{ freq: 523.25, time: 0.0, dur: 0.8 }, // C5
|
||||
{ freq: 659.25, time: 0.1, dur: 0.8 }, // E5
|
||||
{ freq: 783.99, time: 0.2, dur: 0.8 }, // G5
|
||||
{ freq: 987.77, time: 0.3, dur: 0.8 }, // B5 (Maj7)
|
||||
{ freq: 1046.50, time: 0.4, dur: 2.0 }, // C6 (High C resolve)
|
||||
// Add a bass root note at the end for fullness
|
||||
{ freq: 523.25, time: 0.4, dur: 2.0 } // C5
|
||||
];
|
||||
|
||||
sequence.forEach(note => playNote(note.freq, now + note.time, note.dur));
|
||||
}
|
||||
|
||||
export function cleanupAudio() {
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
masterGain = null;
|
||||
}
|
||||
@@ -1,3 +1,29 @@
|
||||
export function calculateLineHints(line) {
|
||||
const hints = [];
|
||||
let currentRun = 0;
|
||||
|
||||
for (const cell of line) {
|
||||
if (cell === 1) {
|
||||
currentRun++;
|
||||
} else {
|
||||
if (currentRun > 0) {
|
||||
hints.push(currentRun);
|
||||
currentRun = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentRun > 0) {
|
||||
hints.push(currentRun);
|
||||
}
|
||||
return hints.length > 0 ? hints : [0];
|
||||
}
|
||||
|
||||
export function validateLine(line, targetHints) {
|
||||
const currentHints = calculateLineHints(line);
|
||||
if (currentHints.length !== targetHints.length) return false;
|
||||
return currentHints.every((h, i) => h === targetHints[i]);
|
||||
}
|
||||
|
||||
export function calculateHints(grid) {
|
||||
if (!grid || grid.length === 0) return { rowHints: [], colHints: [] };
|
||||
|
||||
@@ -7,34 +33,16 @@ export function calculateHints(grid) {
|
||||
|
||||
// Row Hints
|
||||
for (let r = 0; r < size; r++) {
|
||||
const hints = [];
|
||||
let count = 0;
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (grid[r][c] === 1) {
|
||||
count++;
|
||||
} else if (count > 0) {
|
||||
hints.push(count);
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
if (count > 0) hints.push(count);
|
||||
rowHints.push(hints.length > 0 ? hints : [0]);
|
||||
rowHints.push(calculateLineHints(grid[r]));
|
||||
}
|
||||
|
||||
// Col Hints
|
||||
for (let c = 0; c < size; c++) {
|
||||
const hints = [];
|
||||
let count = 0;
|
||||
const col = [];
|
||||
for (let r = 0; r < size; r++) {
|
||||
if (grid[r][c] === 1) {
|
||||
count++;
|
||||
} else if (count > 0) {
|
||||
hints.push(count);
|
||||
count = 0;
|
||||
}
|
||||
col.push(grid[r][c]);
|
||||
}
|
||||
if (count > 0) hints.push(count);
|
||||
colHints.push(hints.length > 0 ? hints : [0]);
|
||||
colHints.push(calculateLineHints(col));
|
||||
}
|
||||
|
||||
return { rowHints, colHints };
|
||||
@@ -68,7 +76,7 @@ export function calculateDifficulty(density, size = 10) {
|
||||
45: [2, 0, 0, 0, 1, 82, 100, 100, 100],
|
||||
50: [2, 0, 0, 0, 1, 73, 100, 100, 100],
|
||||
60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
|
||||
70: [0, 0, 0, 0, 0, 16, 100, 100, 100],
|
||||
71: [0, 0, 0, 0, 0, 16, 100, 100, 100],
|
||||
80: [0, 0, 0, 0, 0, 1, 100, 100, 100]
|
||||
};
|
||||
|
||||
|
||||
284
src/utils/shareUtils.js
Normal file
284
src/utils/shareUtils.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
|
||||
export function buildShareCanvas(data, t, formattedTime) {
|
||||
const { grid, size, currentDensity, guideUsageCount } = data;
|
||||
if (!grid || !grid.length) return null;
|
||||
|
||||
const appUrl = 'https://nonograms.7u.pl/';
|
||||
const maxBoard = 640;
|
||||
const cellSize = Math.max(8, Math.floor(maxBoard / size));
|
||||
const boardSize = cellSize * size;
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40; // New space for difficulty/guide info
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
const bg = ctx.createLinearGradient(0, 0, width, height);
|
||||
bg.addColorStop(0, '#1b2a4a');
|
||||
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';
|
||||
ctx.fillText(t('app.title'), padding, padding + 10);
|
||||
ctx.font = '600 16px "Segoe UI", sans-serif';
|
||||
ctx.fillText(`${t('win.time')} ${formattedTime}`, padding, padding + 34);
|
||||
|
||||
// Difficulty & Density Info
|
||||
const densityPercent = Math.round(currentDensity * 100);
|
||||
const { level: difficultyKey } = calculateDifficulty(currentDensity, size);
|
||||
let diffColor = '#33ff33';
|
||||
if (difficultyKey === 'extreme') diffColor = '#ff3333';
|
||||
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
|
||||
else if (difficultyKey === 'harder') diffColor = '#ffff33';
|
||||
|
||||
const difficultyText = t(`difficulty.${difficultyKey}`);
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
|
||||
// Right aligned difficulty info
|
||||
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
|
||||
const diffWidth = ctx.measureText(diffLabel).width;
|
||||
ctx.fillStyle = diffColor;
|
||||
ctx.fillText(diffLabel, width - padding - diffWidth, padding + 34);
|
||||
|
||||
const gridX = padding;
|
||||
const gridY = padding + headerHeight;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.fillRect(gridX, gridY, boardSize, boardSize);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= size; i++) {
|
||||
const x = gridX + i * cellSize;
|
||||
const y = gridY + i * cellSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, gridY);
|
||||
ctx.lineTo(x, gridY + boardSize);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gridX, y);
|
||||
ctx.lineTo(gridX + boardSize, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#00f2fe';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const state = grid[r]?.[c];
|
||||
if (state === 1) {
|
||||
const x = gridX + c * cellSize + 1;
|
||||
const y = gridY + r * cellSize + 1;
|
||||
ctx.fillRect(x, y, cellSize - 2, cellSize - 2);
|
||||
} else if (state === 2) {
|
||||
const x = gridX + c * cellSize + cellSize * 0.2;
|
||||
const y = gridY + r * cellSize + cellSize * 0.2;
|
||||
const d = cellSize * 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + d, y + d);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + d, y);
|
||||
ctx.lineTo(x, y + d);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guide Usage Info (Dirty Flag)
|
||||
if (guideUsageCount > 0) {
|
||||
ctx.fillStyle = '#ff4d4d';
|
||||
ctx.font = '600 14px "Segoe UI", sans-serif';
|
||||
|
||||
const totalCells = size * size;
|
||||
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.fillStyle = 'rgba(255, 255, 255, 0.75)';
|
||||
ctx.font = '500 14px "Segoe UI", sans-serif';
|
||||
ctx.fillText(appUrl, padding, height - padding + 6);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function buildShareSVG(data, t, formattedTime) {
|
||||
const { grid, size, currentDensity, guideUsageCount } = data;
|
||||
if (!grid || !grid.length) return null;
|
||||
|
||||
const appUrl = 'https://nonograms.7u.pl/';
|
||||
const maxBoard = 640;
|
||||
const cellSize = Math.max(8, Math.floor(maxBoard / size));
|
||||
const boardSize = cellSize * size;
|
||||
const padding = 28;
|
||||
const headerHeight = 64;
|
||||
const footerHeight = 28;
|
||||
const infoHeight = 40;
|
||||
const width = boardSize + padding * 2;
|
||||
const height = boardSize + padding * 2 + headerHeight + footerHeight + infoHeight;
|
||||
|
||||
// Colors
|
||||
const bgGradientStart = '#1b2a4a';
|
||||
const bgGradientEnd = '#0a1324';
|
||||
const overlayColor = 'rgba(0, 0, 0, 0.35)';
|
||||
const textColor = '#e8fbff';
|
||||
const gridColor = 'rgba(255, 255, 255, 0.06)';
|
||||
const gridLineColor = 'rgba(255, 255, 255, 0.12)';
|
||||
const filledColor = '#00f2fe';
|
||||
const crossColor = 'rgba(255, 255, 255, 0.5)';
|
||||
const urlColor = 'rgba(255, 255, 255, 0.75)';
|
||||
|
||||
// Difficulty Logic
|
||||
const densityPercent = Math.round(currentDensity * 100);
|
||||
const { level: difficultyKey } = calculateDifficulty(currentDensity, size);
|
||||
|
||||
let diffColor = '#33ff33';
|
||||
if (difficultyKey === 'extreme') diffColor = '#ff3333';
|
||||
else if (difficultyKey === 'hardest') diffColor = '#ff9933';
|
||||
else if (difficultyKey === 'harder') diffColor = '#ffff33';
|
||||
const difficultyText = t(`difficulty.${difficultyKey}`);
|
||||
const diffLabel = `${t('win.difficulty')} ${difficultyText} (${densityPercent}%)`;
|
||||
|
||||
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`;
|
||||
|
||||
// Background
|
||||
svgContent += `
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${bgGradientStart}"/>
|
||||
<stop offset="100%" stop-color="${bgGradientEnd}"/>
|
||||
</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
|
||||
svgContent += `
|
||||
<text x="${padding}" y="${padding + 28}" font-family="Segoe UI, sans-serif" font-weight="700" font-size="26" fill="${textColor}">${t('app.title')}</text>
|
||||
<text x="${padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="16" fill="${textColor}">${t('win.time')} ${formattedTime}</text>
|
||||
`;
|
||||
|
||||
// Text: Difficulty (Right Aligned - manual approx or end anchor)
|
||||
svgContent += `
|
||||
<text x="${width - padding}" y="${padding + 56}" font-family="Segoe UI, sans-serif" font-weight="600" font-size="14" fill="${diffColor}" text-anchor="end">${diffLabel}</text>
|
||||
`;
|
||||
|
||||
const gridX = padding;
|
||||
const gridY = padding + headerHeight;
|
||||
|
||||
// Grid Background
|
||||
svgContent += `<rect x="${gridX}" y="${gridY}" width="${boardSize}" height="${boardSize}" fill="${gridColor}"/>`;
|
||||
|
||||
// Grid Lines
|
||||
let gridLines = '';
|
||||
for (let i = 0; i <= size; i++) {
|
||||
const pos = i * cellSize;
|
||||
// Vertical
|
||||
gridLines += `<line x1="${gridX + pos}" y1="${gridY}" x2="${gridX + pos}" y2="${gridY + boardSize}" stroke="${gridLineColor}" stroke-width="1"/>`;
|
||||
// Horizontal
|
||||
gridLines += `<line x1="${gridX}" y1="${gridY + pos}" x2="${gridX + boardSize}" y2="${gridY + pos}" stroke="${gridLineColor}" stroke-width="1"/>`;
|
||||
}
|
||||
svgContent += gridLines;
|
||||
|
||||
// Cells
|
||||
let cells = '';
|
||||
const lineWidth = Math.max(1.5, Math.floor(cellSize * 0.12));
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const state = grid[r]?.[c];
|
||||
const cx = gridX + c * cellSize;
|
||||
const cy = gridY + r * cellSize;
|
||||
|
||||
if (state === 1) { // Filled
|
||||
cells += `<rect x="${cx + 1}" y="${cy + 1}" width="${cellSize - 2}" height="${cellSize - 2}" fill="${filledColor}"/>`;
|
||||
} else if (state === 2) { // Cross
|
||||
const d = cellSize * 0.6;
|
||||
const off = cellSize * 0.2;
|
||||
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}"
|
||||
stroke="${crossColor}" stroke-width="${lineWidth}" stroke-linecap="round"/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
svgContent += cells;
|
||||
|
||||
// Guide Usage
|
||||
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>`;
|
||||
}
|
||||
|
||||
// URL
|
||||
svgContent += `<text x="${padding}" y="${height - padding + 6}" font-family="Segoe UI, sans-serif" font-weight="500" font-size="14" fill="${urlColor}">${appUrl}</text>`;
|
||||
|
||||
svgContent += '</svg>';
|
||||
return svgContent;
|
||||
}
|
||||
|
||||
export const canvasToBlob = (canvas) => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/png'));
|
||||
|
||||
export const createShareBlob = async (data, t, formattedTime) => {
|
||||
const canvas = buildShareCanvas(data, t, formattedTime);
|
||||
if (!canvas) return null;
|
||||
return canvasToBlob(canvas);
|
||||
};
|
||||
|
||||
export const downloadShareSVG = (data, t, formattedTime) => {
|
||||
const svgString = buildShareSVG(data, t, formattedTime);
|
||||
if (!svgString) return;
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nonogram-${data.size}x${data.size}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadShareImage = async (data, t, formattedTime) => {
|
||||
const blob = await createShareBlob(data, t, formattedTime);
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nonogram-${data.size}x${data.size}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const buildShareUrl = (target, text, url) => {
|
||||
const encodedText = encodeURIComponent(text);
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
if (target === 'x') {
|
||||
return `https://x.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
|
||||
}
|
||||
if (target === 'facebook') {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}"e=${encodedText}`;
|
||||
}
|
||||
if (target === 'whatsapp') {
|
||||
return `https://wa.me/?text=${encodeURIComponent(`${text} ${url}`)}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
5
src/utils/timeUtils.js
Normal file
5
src/utils/timeUtils.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function formatTime(seconds) {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
Reference in New Issue
Block a user