Compare commits
79 Commits
b8cf4d3cf4
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a36768cd | |||
| 315fb29eac | |||
| 395e9caff4 | |||
| e1c73181d4 | |||
| 874e35bba3 | |||
| 69b04d3336 | |||
| c5b212234a | |||
| 8e0ddf3a72 | |||
| bfb24cfb03 | |||
| 8d3bde8d38 | |||
| d25fa67100 | |||
| c7834bd8bf | |||
| 7d405ef0f6 | |||
| 431b534477 | |||
| ebf9030185 | |||
| 8f52f5daa5 | |||
| 2dc68ab8d0 | |||
| 993ced424e | |||
| 1b0b6a671a | |||
| e39ac9a794 | |||
| 81ed97b263 | |||
| 61359e6665 | |||
| 8f8f6a6e8e | |||
| a02c17e462 | |||
| 490ca6b4a5 | |||
| 10ed00b7b6 | |||
| a335adeca8 | |||
| 72adab61c7 | |||
| 2c892f3adc | |||
| b6b289cd57 | |||
| ea2e3c573b | |||
| cf2e9de56d | |||
| 4313831e84 | |||
| 60ef352cda | |||
| 91c873ef97 | |||
| c4764e9505 | |||
| 423258ef17 | |||
| 0bd2b905a9 | |||
| 572acc3979 | |||
| 7256809f0b | |||
| 9f89fe4340 | |||
| 7610a4a523 | |||
| 285c486c6e | |||
| 8a97cc5d0d | |||
| c287523fa5 | |||
| eb6b69134f | |||
| 4d50eb97eb | |||
| a1df95d3d4 | |||
| 4a7c088776 | |||
| 6f3ed143e5 | |||
| c5f9da81a9 | |||
| 0decf2324c | |||
| bae864c2d0 | |||
| 96999f740c | |||
| 4982e6ed49 | |||
| 8dde1d7997 | |||
| d5b5df0e62 | |||
| 3fb5d6bde5 | |||
| ee5d3fdb0d | |||
| 0f2f97ff3f | |||
| 70d7242bfe | |||
| 8d74848c3f | |||
| c0a42d7213 | |||
| 7a7551107f | |||
| 133440db4c | |||
| 4ca1aaa981 | |||
| 62ad664ec0 | |||
| 27515639aa | |||
| ad4ea9617c | |||
| 4b2d98fe05 | |||
| 736ed0e5e8 | |||
| 28eb9ad391 | |||
| 86e7a02a5c | |||
| 0f944857b6 | |||
| 3316532cbc | |||
| 40a6725bf1 | |||
| 2d4d03f5d3 | |||
| bf3a869f48 | |||
| f42c04db34 |
1
.gitignore
vendored
@@ -4,3 +4,4 @@ dist
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
dev-dist
|
||||
|
||||
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Nonograms
|
||||
|
||||
Link do aplikacji: https://nonograms.7u.pl
|
||||
@@ -10,5 +10,5 @@ services:
|
||||
- "8081:80"
|
||||
restart: unless-stopped
|
||||
# Uncomment the following lines if you want to mount the configuration locally for development/testing
|
||||
# volumes:
|
||||
# - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
6978
package-lock.json
generated
13
package.json
@@ -1,19 +1,26 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fireworks-js": "^2.10.8",
|
||||
"flag-icons": "^7.5.0",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.4"
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^28.0.0",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
24
public/pwa-192x192.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#43C6AC"/>
|
||||
<stop offset="1" stop-color="#191654"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cell" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#00f2fe"/>
|
||||
<stop offset="1" stop-color="#4facfe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="28" fill="url(#bg)"/>
|
||||
<rect x="28" y="28" width="136" height="136" rx="14" fill="rgba(0,0,0,0.35)"/>
|
||||
<g fill="url(#cell)">
|
||||
<rect x="48" y="48" width="20" height="20" rx="4"/>
|
||||
<rect x="76" y="48" width="20" height="20" rx="4"/>
|
||||
<rect x="104" y="48" width="20" height="20" rx="4"/>
|
||||
<rect x="48" y="76" width="20" height="20" rx="4"/>
|
||||
<rect x="104" y="76" width="20" height="20" rx="4"/>
|
||||
<rect x="48" y="104" width="20" height="20" rx="4"/>
|
||||
<rect x="76" y="104" width="20" height="20" rx="4"/>
|
||||
<rect x="104" y="104" width="20" height="20" rx="4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
24
public/pwa-512x512.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#43C6AC"/>
|
||||
<stop offset="1" stop-color="#191654"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cell" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#00f2fe"/>
|
||||
<stop offset="1" stop-color="#4facfe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="80" fill="url(#bg)"/>
|
||||
<rect x="74" y="74" width="364" height="364" rx="40" fill="rgba(0,0,0,0.35)"/>
|
||||
<g fill="url(#cell)">
|
||||
<rect x="138" y="138" width="54" height="54" rx="10"/>
|
||||
<rect x="214" y="138" width="54" height="54" rx="10"/>
|
||||
<rect x="290" y="138" width="54" height="54" rx="10"/>
|
||||
<rect x="138" y="214" width="54" height="54" rx="10"/>
|
||||
<rect x="290" y="214" width="54" height="54" rx="10"/>
|
||||
<rect x="138" y="290" width="54" height="54" rx="10"/>
|
||||
<rect x="214" y="290" width="54" height="54" rx="10"/>
|
||||
<rect x="290" y="290" width="54" height="54" rx="10"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
239
src/App.vue
@@ -1,43 +1,157 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { usePuzzleStore } from './stores/puzzle';
|
||||
import { useI18n } from './composables/useI18n';
|
||||
import GameBoard from './components/GameBoard.vue';
|
||||
import LevelSelector from './components/LevelSelector.vue';
|
||||
import NavBar from './components/NavBar.vue';
|
||||
import StatusPanel from './components/StatusPanel.vue';
|
||||
import GameActions from './components/GameActions.vue';
|
||||
import GuidePanel from './components/GuidePanel.vue';
|
||||
import WinModal from './components/WinModal.vue';
|
||||
import CustomGameModal from './components/CustomGameModal.vue';
|
||||
import FixedBar from './components/FixedBar.vue';
|
||||
import ReloadPrompt from './components/ReloadPrompt.vue';
|
||||
|
||||
// Main App Entry
|
||||
const store = usePuzzleStore();
|
||||
const { t, locale, setLocale, locales } = useI18n();
|
||||
const showCustomModal = ref(false);
|
||||
const showGuide = ref(false);
|
||||
const deferredPrompt = ref(null);
|
||||
const canInstall = ref(false);
|
||||
const installDismissed = ref(false);
|
||||
const isCoarsePointer = ref(false);
|
||||
const isStandalone = ref(false);
|
||||
const themePreference = ref('system');
|
||||
const appVersion = __APP_VERSION__;
|
||||
let displayModeMedia = null;
|
||||
let prefersColorSchemeMedia = null;
|
||||
|
||||
const installLabel = computed(() => {
|
||||
return isCoarsePointer.value ? t('pwa.installMobile') : t('pwa.installDesktop');
|
||||
});
|
||||
|
||||
const updateStandalone = () => {
|
||||
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||
if (isStandalone.value) {
|
||||
canInstall.value = false;
|
||||
installDismissed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeInstallPrompt = (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt.value = e;
|
||||
if (!isStandalone.value) {
|
||||
canInstall.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
deferredPrompt.value = null;
|
||||
canInstall.value = false;
|
||||
installDismissed.value = true;
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt.value) return;
|
||||
deferredPrompt.value.prompt();
|
||||
const choice = await deferredPrompt.value.userChoice;
|
||||
deferredPrompt.value = null;
|
||||
canInstall.value = false;
|
||||
if (!choice || choice.outcome !== 'accepted') {
|
||||
installDismissed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSystemTheme = () => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const applyTheme = () => {
|
||||
const nextTheme = themePreference.value === 'system' ? resolveSystemTheme() : themePreference.value;
|
||||
document.documentElement.dataset.theme = nextTheme;
|
||||
};
|
||||
|
||||
const setThemePreference = (value) => {
|
||||
themePreference.value = value;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('theme', value);
|
||||
}
|
||||
applyTheme();
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
if (themePreference.value === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.loadState()) {
|
||||
store.initGame(); // Inicjalizacja domyślnej gry jeśli brak zapisu
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
isCoarsePointer.value = window.matchMedia('(pointer: coarse)').matches;
|
||||
const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
||||
if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') {
|
||||
themePreference.value = storedTheme;
|
||||
}
|
||||
applyTheme();
|
||||
prefersColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
if (prefersColorSchemeMedia?.addEventListener) {
|
||||
prefersColorSchemeMedia.addEventListener('change', handleSystemThemeChange);
|
||||
} else if (prefersColorSchemeMedia?.addListener) {
|
||||
prefersColorSchemeMedia.addListener(handleSystemThemeChange);
|
||||
}
|
||||
updateStandalone();
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
displayModeMedia = window.matchMedia('(display-mode: standalone)');
|
||||
if (displayModeMedia?.addEventListener) {
|
||||
displayModeMedia.addEventListener('change', updateStandalone);
|
||||
} else if (displayModeMedia?.addListener) {
|
||||
displayModeMedia.addListener(updateStandalone);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
if (prefersColorSchemeMedia?.removeEventListener) {
|
||||
prefersColorSchemeMedia.removeEventListener('change', handleSystemThemeChange);
|
||||
} else if (prefersColorSchemeMedia?.removeListener) {
|
||||
prefersColorSchemeMedia.removeListener(handleSystemThemeChange);
|
||||
}
|
||||
if (displayModeMedia?.removeEventListener) {
|
||||
displayModeMedia.removeEventListener('change', updateStandalone);
|
||||
} else if (displayModeMedia?.removeListener) {
|
||||
displayModeMedia.removeListener(updateStandalone);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="game-container">
|
||||
<NavBar
|
||||
@open-custom="showCustomModal = true"
|
||||
@toggle-guide="showGuide = !showGuide"
|
||||
@set-theme="setThemePreference"
|
||||
/>
|
||||
<FixedBar />
|
||||
|
||||
<header class="game-header">
|
||||
<h1>NONOGRAMY</h1>
|
||||
<div class="underline"></div>
|
||||
</header>
|
||||
<div v-if="canInstall && !installDismissed" class="install-banner">
|
||||
<div class="install-text">{{ t('pwa.installTitle') }}</div>
|
||||
<div class="install-actions">
|
||||
<button class="btn-neon secondary install-btn" @click="handleInstall">
|
||||
{{ installLabel }}
|
||||
</button>
|
||||
<button class="install-close" @click="installDismissed = true">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-layout">
|
||||
<!-- Level Selection -->
|
||||
<LevelSelector
|
||||
@open-custom="showCustomModal = true"
|
||||
@toggle-guide="showGuide = !showGuide"
|
||||
/>
|
||||
|
||||
<!-- Guide Panel (Conditional) -->
|
||||
<transition name="fade">
|
||||
<GuidePanel v-if="showGuide" />
|
||||
@@ -46,19 +160,21 @@ onMounted(() => {
|
||||
<!-- Status Panel (Time, Moves, Progress) -->
|
||||
<StatusPanel />
|
||||
|
||||
<!-- Game Actions (Reset, Random, Undo, Check) -->
|
||||
<GameActions />
|
||||
|
||||
<!-- Game Board -->
|
||||
<section class="board-section">
|
||||
<GameBoard />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="app-version">
|
||||
v{{ appVersion }}
|
||||
</footer>
|
||||
|
||||
<!-- Modals Teleport -->
|
||||
<Teleport to="body">
|
||||
<WinModal v-if="store.isGameWon" />
|
||||
<CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" />
|
||||
<ReloadPrompt />
|
||||
</Teleport>
|
||||
</main>
|
||||
</template>
|
||||
@@ -73,60 +189,77 @@ onMounted(() => {
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 40px;
|
||||
.install-banner {
|
||||
background: var(--banner-bg);
|
||||
border: 1px solid var(--banner-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--banner-shadow);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.5rem;
|
||||
margin: 0;
|
||||
letter-spacing: 5px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 255, 0.2);
|
||||
.install-text {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.underline {
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background: var(--primary-accent);
|
||||
margin: 10px auto 0;
|
||||
box-shadow: 0 0 10px var(--primary-accent);
|
||||
.install-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.install-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Remove old glass panel style from game-layout since we split it */
|
||||
.board-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-header {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
.app-version {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(5px);
|
||||
border-top: 1px solid var(--panel-border);
|
||||
z-index: 90;
|
||||
}
|
||||
</style>
|
||||
3
src/assets/brands/facebook.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M13.5 9.2V7.1c0-1 .7-1.2 1.2-1.2h2V2.4h-2.9c-3.2 0-4 2.4-4 3.9v2.9H7.7V13h2.1v8.6h3.7V13h2.8l.4-3.8h-3.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 203 B |
3
src/assets/brands/whatsapp.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M20.5 3.5A11 11 0 0 0 2.7 17.7L2 22l4.4-1.2a11 11 0 0 0 5.6 1.4h.1a11 11 0 0 0 7.8-18.7Zm-8.4 16.7h-.1a9 9 0 0 1-4.6-1.3l-.3-.2-2.7.8.7-2.6-.2-.3a9 9 0 1 1 7.2 3.6Zm5-6.7c-.3-.1-1.8-.9-2.1-1s-.5-.1-.7.2-.8 1-1 1.2-.4.2-.7.1a7.2 7.2 0 0 1-2.1-1.3 8 8 0 0 1-1.5-1.9c-.2-.3 0-.5.1-.7l.5-.6c.1-.1.2-.3.3-.5s0-.3 0-.5c-.1-.1-.7-1.7-1-2.3-.3-.7-.6-.6-.7-.6h-.6c-.2 0-.5.1-.7.3s-1 1-1 2.4 1 2.7 1.1 2.9c.1.2 2 3 5 4.2.7.3 1.3.5 1.7.6.7.2 1.4.2 1.9.1.6-.1 1.8-.7 2-1.4.2-.7.2-1.3.1-1.4s-.3-.2-.6-.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
3
src/assets/brands/x.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M18.9 3H22l-7.2 8.2L23 21h-6.6l-5.2-6.2L5.7 21H2.6l7.7-8.7L1 3h6.8l4.7 5.6L18.9 3Zm-1.2 15.9h1.8L6.4 5.1H4.4l13.3 13.8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
1
src/assets/flags/af.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#007749"/><path d="M0 0l12 8L0 16z" fill="#000"/><path d="M0 0l12 8L0 16z" fill="none" stroke="#fff" stroke-width="2"/><path d="M24 0l-12 8 12 8z" fill="#007749"/><rect y="0" width="24" height="5.33" fill="#e03c31"/><rect y="10.67" width="24" height="5.33" fill="#001489"/><path d="M0 5.33h24v5.33H0z" fill="#fff"/><path d="M0 0l12 8L0 16z" fill="#000"/><path d="M0 6.5l10 1.5L0 9.5z" fill="#fc0"/><path d="M12 8L24 0v16z" fill="#007749"/><path d="M12 8L24 0h-6L12 8z" fill="#e03c31"/><path d="M12 8L24 16h-6L12 8z" fill="#001489"/><path d="M0 0l12 8L0 16" stroke="#fff" stroke-width="2" fill="none"/><path d="M12 8H24" stroke="#fff" stroke-width="2" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 765 B |
5
src/assets/flags/am.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#ed1c24"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#fecd00"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#008000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
5
src/assets/flags/az.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#00b5e2"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ef3340"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#509e2f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
1
src/assets/flags/bo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#be0000"/><path d="M0 16L12 4 24 16" fill="#003893"/><circle cx="12" cy="10" r="3" fill="#fcd116"/></svg>
|
||||
|
After Width: | Height: | Size: 200 B |
1
src/assets/flags/ceb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="8" fill="#0038a8"/><rect y="8" width="24" height="8" fill="#ce1126"/><path d="M0 0l11 8L0 16z" fill="#fff"/><circle cx="4" cy="8" r="1.5" fill="#fcd116"/></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
1
src/assets/flags/ckb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#ed1c24"/><rect y="5.33" width="24" height="5.33" fill="#fff"/><rect y="10.67" width="24" height="5.33" fill="#218a42"/><circle cx="12" cy="8" r="2.5" fill="#ffc61e"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
5
src/assets/flags/fa.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#239f40"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ffffff"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#da0000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
5
src/assets/flags/globe.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#1e90ff"/>
|
||||
<circle cx="12" cy="8" r="5" fill="#ffffff"/>
|
||||
<path d="M12 3 v10 M7 8 h10" stroke="#1e90ff" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |
1
src/assets/flags/gu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#ff9933"/><rect y="5.33" width="24" height="5.33" fill="#fff"/><rect y="10.67" width="24" height="5.33" fill="#138808"/><circle cx="12" cy="8" r="2" fill="#000080"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
6
src/assets/flags/he.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#ffffff"/>
|
||||
<rect y="2" width="24" height="2" fill="#0038b8"/>
|
||||
<rect y="12" width="24" height="2" fill="#0038b8"/>
|
||||
<polygon points="12,5 10,9 14,9" fill="#0038b8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
src/assets/flags/ht.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="8" fill="#d21034"/><rect y="8" width="24" height="8" fill="#00209f"/><rect x="8" y="4" width="8" height="8" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 212 B |
5
src/assets/flags/hy.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#d90012"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#0033a0"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#f2a800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
4
src/assets/flags/id.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="8" fill="#ff0000"/>
|
||||
<rect y="8" width="24" height="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 168 B |
1
src/assets/flags/ig.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="16" fill="#008751"/><rect x="8" width="8" height="16" fill="#fff"/><rect x="16" width="8" height="16" fill="#008751"/></svg>
|
||||
|
After Width: | Height: | Size: 208 B |
1
src/assets/flags/ilo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="8" fill="#0038a8"/><rect y="8" width="24" height="8" fill="#ce1126"/><path d="M0 0l11 8L0 16z" fill="#fff"/><circle cx="4" cy="8" r="1.5" fill="#fcd116"/></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
4
src/assets/flags/ja.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#ffffff"/>
|
||||
<circle cx="12" cy="8" r="4" fill="#bc002d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 164 B |
1
src/assets/flags/jv.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="8" fill="#ff0000"/><rect y="8" width="24" height="8" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 157 B |
1
src/assets/flags/kk.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#00afca"/><circle cx="12" cy="8" r="4" fill="#fec500"/><path d="M0 0v16l4-8z" fill="#fec500"/></svg>
|
||||
|
After Width: | Height: | Size: 195 B |
1
src/assets/flags/km.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="4" fill="#032ea1"/><rect y="4" width="24" height="8" fill="#e00025"/><rect y="12" width="24" height="4" fill="#032ea1"/><path d="M12 5l-2 5h4z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
1
src/assets/flags/kn.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#ff9933"/><rect y="5.33" width="24" height="5.33" fill="#fff"/><rect y="10.67" width="24" height="5.33" fill="#138808"/><circle cx="12" cy="8" r="2" fill="#000080"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
5
src/assets/flags/ko.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#ffffff"/>
|
||||
<circle cx="12" cy="8" r="3.5" fill="#003478"/>
|
||||
<circle cx="12" cy="8" r="2.2" fill="#c60c30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 216 B |
1
src/assets/flags/ku.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#ed1c24"/><rect y="5.33" width="24" height="5.33" fill="#fff"/><rect y="10.67" width="24" height="5.33" fill="#218a42"/><circle cx="12" cy="8" r="2.5" fill="#ffc61e"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
1
src/assets/flags/ky.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#e61809"/><rect y="3" width="24" height="10" fill="#ffc61e"/><circle cx="12" cy="8" r="3" fill="#e61809"/><path d="M12 5l1 1-1 1-1-1z" fill="#ffc61e"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
1
src/assets/flags/lo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="4" fill="#ce1126"/><rect y="4" width="24" height="8" fill="#002868"/><rect y="12" width="24" height="4" fill="#ce1126"/><circle cx="12" cy="8" r="2.5" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 255 B |
1
src/assets/flags/mn.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#da2032"/><rect y="5.33" width="24" height="5.33" fill="#0066b3"/><rect y="10.67" width="24" height="5.33" fill="#da2032"/><path d="M4 2l2 4h-4z" fill="#fdd600"/></svg>
|
||||
|
After Width: | Height: | Size: 265 B |
1
src/assets/flags/mr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#ff9933"/><rect y="5.33" width="24" height="5.33" fill="#fff"/><rect y="10.67" width="24" height="5.33" fill="#138808"/><circle cx="12" cy="8" r="2" fill="#000080"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
4
src/assets/flags/ms.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="8" fill="#ff0000"/>
|
||||
<rect y="8" width="24" height="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 168 B |
1
src/assets/flags/my.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#fecb00"/><rect y="5.33" width="24" height="5.33" fill="#34b233"/><rect y="10.67" width="24" height="5.33" fill="#ea2839"/><path d="M12 4l2.5 7h-8L9 4l-2.5 7h8z" fill="#fff" transform="translate(0 1)"/></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
1
src/assets/flags/ne.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 0l16 8L0 16z" fill="#dc143c"/><path d="M0 0l16 8L0 16" stroke="#003893" stroke-width="2" fill="none"/><path d="M4 6l2 2-2 2z" fill="#fff"/><path d="M4 10l2 2-2 2z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 255 B |
1
src/assets/flags/om.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#078930"/><rect y="5.33" width="24" height="5.33" fill="#fcdD09"/><rect y="10.67" width="24" height="5.33" fill="#da121a"/><circle cx="12" cy="8" r="2.5" fill="#0f47af"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
1
src/assets/flags/pa.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><rect width="6" height="16" fill="#01411c"/><circle cx="15" cy="8" r="3" fill="#01411c"/></svg>
|
||||
|
After Width: | Height: | Size: 197 B |
1
src/assets/flags/ps.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" fill="#000"/><rect y="5.33" width="24" height="5.33" fill="#be0000"/><rect y="10.67" width="24" height="5.33" fill="#007a36"/><path d="M12 4l3 6h-6z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 260 B |
1
src/assets/flags/rn.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><path d="M0 0l24 16m0-16L0 16" stroke="#ce1126" stroke-width="4"/><circle cx="12" cy="8" r="3" fill="#fff"/><circle cx="12" cy="8" r="2" fill="#1eb53a"/><circle cx="12" cy="8" r="1" fill="#ce1126"/><circle cx="12" cy="8" r="0.5" fill="#1eb53a"/></svg>
|
||||
|
After Width: | Height: | Size: 353 B |
1
src/assets/flags/rw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="8" fill="#00a1de"/><rect y="8" width="24" height="4" fill="#fad201"/><rect y="12" width="24" height="4" fill="#20603d"/><circle cx="18" cy="4" r="2" fill="#fad201"/></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
1
src/assets/flags/so.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#4189dd"/><path d="M12 4l1.5 5h5l-4 3 1.5 5-4-3-4 3 1.5-5-4-3h5z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 180 B |
5
src/assets/flags/sw.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#3a75c4"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ffde00"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#3a75c4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
6
src/assets/flags/ta.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#ff9933"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ffffff"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#128807"/>
|
||||
<circle cx="12" cy="8" r="2" fill="#000080"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
6
src/assets/flags/te.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#ff9933"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ffffff"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#128807"/>
|
||||
<circle cx="12" cy="8" r="2" fill="#000080"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
7
src/assets/flags/th.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="3" fill="#ff0000"/>
|
||||
<rect y="3" width="24" height="2" fill="#ffffff"/>
|
||||
<rect y="5" width="24" height="6" fill="#2e2a8c"/>
|
||||
<rect y="11" width="24" height="2" fill="#ffffff"/>
|
||||
<rect y="13" width="24" height="3" fill="#ff0000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
1
src/assets/flags/ti.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 0l24 8L0 16z" fill="#ea0437"/><path d="M0 0l24 8H0z" fill="#0cad4d"/><path d="M0 16l24-8H0z" fill="#418fde"/><circle cx="8" cy="8" r="3" fill="#ffc61e"/></svg>
|
||||
|
After Width: | Height: | Size: 231 B |
5
src/assets/flags/tl.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="8" fill="#0038a8"/>
|
||||
<rect y="8" width="24" height="8" fill="#ce1126"/>
|
||||
<polygon points="0,8 8,0 8,16" fill="#fcd116"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 218 B |
4
src/assets/flags/tl_placeholder.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#cccccc"/>
|
||||
<text x="12" y="10" font-size="6" text-anchor="middle" fill="#666">TL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 195 B |
5
src/assets/flags/uz.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="5.33" fill="#1eb53a"/>
|
||||
<rect y="5.33" width="24" height="5.33" fill="#ffffff"/>
|
||||
<rect y="10.67" width="24" height="5.33" fill="#0099b5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
4
src/assets/flags/vi.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="16" fill="#da251d"/>
|
||||
<polygon points="12,4.5 13.2,8 16.9,8 13.9,10 15.1,13.5 12,11.5 8.9,13.5 10.1,10 7.1,8 10.8,8" fill="#ffdd00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
1
src/assets/flags/wo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="16" fill="#00853f"/><rect x="8" width="8" height="16" fill="#fdef42"/><rect x="16" width="8" height="16" fill="#e31b23"/><path d="M12 5l1.5 5h5l-4 3 1.5 5-4-3-4 3 1.5-5-4-3h5z" fill="#00853f"/></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
1
src/assets/flags/yo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="16" fill="#008751"/><rect x="8" width="8" height="16" fill="#fff"/><rect x="16" width="8" height="16" fill="#008751"/></svg>
|
||||
|
After Width: | Height: | Size: 208 B |
@@ -22,22 +22,43 @@ const cellClass = computed(() => {
|
||||
});
|
||||
|
||||
let lastTap = 0;
|
||||
let longPressTimer = null;
|
||||
let longPressTriggered = false;
|
||||
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
if (e.button === 0) emit('start-drag', props.r, props.c, false);
|
||||
if (e.button === 2) emit('start-drag', props.r, props.c, true);
|
||||
if (e.button === 0) emit('start-drag', props.r, props.c, false, false);
|
||||
if (e.button === 2) emit('start-drag', props.r, props.c, true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Touch logic
|
||||
const now = Date.now();
|
||||
const isDoubleTap = now - lastTap < 300;
|
||||
lastTap = now;
|
||||
if (isDoubleTap) {
|
||||
emit('start-drag', props.r, props.c, true);
|
||||
if (now - lastTap < 300) {
|
||||
// Double tap -> X (Force)
|
||||
emit('start-drag', props.r, props.c, true, true);
|
||||
lastTap = 0;
|
||||
} else {
|
||||
emit('start-drag', props.r, props.c, false);
|
||||
// Single tap / Start drag -> Fill
|
||||
emit('start-drag', props.r, props.c, false, false);
|
||||
lastTap = now;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e) => {
|
||||
// Handled in pointerdown
|
||||
};
|
||||
|
||||
const handlePointerCancel = (e) => {
|
||||
// Handled in pointerdown
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +68,10 @@ const handlePointerDown = (e) => {
|
||||
:data-r="props.r"
|
||||
:data-c="props.c"
|
||||
@pointerdown.prevent="handlePointerDown"
|
||||
@mouseenter="emit('enter-cell', props.r, props.c)"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="handlePointerCancel"
|
||||
@pointerleave="handlePointerCancel"
|
||||
@mouseenter="emit('enter-cell', props.r, props.c, $event)"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span v-if="props.state === 2" class="cross-mark">×</span>
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { calculateDifficulty } from '@/utils/puzzleUtils';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const store = usePuzzleStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const customSize = ref(10);
|
||||
const fillRate = ref(50);
|
||||
const errorMsg = ref('');
|
||||
|
||||
const snapToStep = (value, step) => {
|
||||
const rounded = Math.round(value / step) * step;
|
||||
return Math.max(5, Math.min(80, rounded));
|
||||
};
|
||||
|
||||
const handleSnap = () => {
|
||||
customSize.value = snapToStep(Number(customSize.value), 5);
|
||||
};
|
||||
|
||||
const difficultyLevel = computed(() => {
|
||||
return calculateDifficulty(fillRate.value / 100);
|
||||
});
|
||||
|
||||
const difficultyColor = computed(() => {
|
||||
switch(difficultyLevel.value) {
|
||||
case 'extreme': return '#ff3333';
|
||||
case 'hardest': return '#ff9933';
|
||||
case 'harder': return '#ffff33';
|
||||
case 'easy': return '#33ff33';
|
||||
default: return '#33ff33';
|
||||
}
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
const size = parseInt(customSize.value);
|
||||
if (isNaN(size) || size < 5 || size > 100) {
|
||||
errorMsg.value = 'Rozmiar musi być między 5 a 100!';
|
||||
if (isNaN(size) || size < 5 || size > 80) {
|
||||
errorMsg.value = t('custom.sizeError');
|
||||
return;
|
||||
}
|
||||
|
||||
store.initCustomGame(size);
|
||||
store.initCustomGame(size, fillRate.value / 100);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
@@ -23,24 +50,53 @@ const confirm = () => {
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="emit('close')">
|
||||
<div class="modal glass-panel">
|
||||
<h2>GRA WŁASNA</h2>
|
||||
<p>Wprowadź rozmiar siatki (5 - 100):</p>
|
||||
<h2>{{ t('custom.title') }}</h2>
|
||||
<p>{{ t('custom.prompt') }}</p>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="range-value">{{ customSize }}</div>
|
||||
<input
|
||||
type="number"
|
||||
type="range"
|
||||
v-model="customSize"
|
||||
min="5"
|
||||
max="100"
|
||||
@keyup.enter="confirm"
|
||||
max="80"
|
||||
step="1"
|
||||
@change="handleSnap"
|
||||
/>
|
||||
<div class="range-scale">
|
||||
<span>5</span>
|
||||
<span>80</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{{ t('custom.fillRate') }}</p>
|
||||
<div class="input-group">
|
||||
<div class="range-value">{{ fillRate }}%</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model="fillRate"
|
||||
min="10"
|
||||
max="90"
|
||||
step="5"
|
||||
/>
|
||||
<div class="range-scale">
|
||||
<span>10%</span>
|
||||
<span>90%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="difficulty-indicator">
|
||||
<span class="label">{{ t('custom.difficulty') }}:</span>
|
||||
<span class="value" :style="{ color: difficultyColor }">
|
||||
{{ t(`difficulty.${difficultyLevel}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-neon secondary" @click="emit('close')">Anuluj</button>
|
||||
<button class="btn-neon" @click="confirm">Start</button>
|
||||
<button class="btn-neon secondary" @click="emit('close')">{{ t('custom.cancel') }}</button>
|
||||
<button class="btn-neon" @click="confirm">{{ t('custom.start') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +109,7 @@ const confirm = () => {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -70,6 +126,7 @@ const confirm = () => {
|
||||
border: 1px solid var(--accent-cyan);
|
||||
box-shadow: 0 0 50px rgba(0, 242, 255, 0.2);
|
||||
animation: slideUp 0.3s ease;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -87,23 +144,86 @@ p {
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 8px;
|
||||
width: 100px;
|
||||
.range-value {
|
||||
min-width: 64px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-bg-strong);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-strong);
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: min(300px, 70vw);
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 255, 0.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-strong);
|
||||
border: 2px solid var(--accent-cyan);
|
||||
box-shadow: 0 0 12px rgba(0, 242, 255, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-strong);
|
||||
border: 2px solid var(--accent-cyan);
|
||||
box-shadow: 0 0 12px rgba(0, 242, 255, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range-scale {
|
||||
width: min(300px, 70vw);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.difficulty-indicator {
|
||||
margin: 20px 0;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
height: 1.5em; /* Reserve space for one line of text */
|
||||
}
|
||||
|
||||
.difficulty-indicator .label {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.difficulty-indicator .value {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
transition: color 0.3s ease;
|
||||
display: inline-block;
|
||||
min-width: 120px; /* Reserve space for longest text */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { formatTime } = useTimer();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isVisible = ref(false);
|
||||
const isProgressVisible = ref(true); // Toggle for progress percentage
|
||||
@@ -33,14 +35,14 @@ const toggleVisibility = () => {
|
||||
<div id="fixed-bar" :class="{ visible: isVisible }">
|
||||
<div class="fixed-content">
|
||||
<div class="fixed-stat">
|
||||
<span>Czas:</span>
|
||||
<span>{{ t('fixed.time') }}</span>
|
||||
<span>{{ formattedTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="fixed-stat">
|
||||
<span>Postęp:</span>
|
||||
<span>{{ t('fixed.progress') }}</span>
|
||||
<span style="min-width: 60px; text-align: right;">{{ progressText }}</span>
|
||||
<button class="btn-eye" @click="toggleVisibility" :title="isProgressVisible ? 'Ukryj' : 'Pokaż'">
|
||||
<button class="btn-eye" @click="toggleVisibility" :title="isProgressVisible ? t('fixed.hide') : t('fixed.show')">
|
||||
<span v-if="isProgressVisible">👁️</span>
|
||||
<span v-else>🔒</span>
|
||||
</button>
|
||||
@@ -60,16 +62,16 @@ const toggleVisibility = () => {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
background: var(--fixed-bar-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid var(--fixed-bar-border);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
box-shadow: var(--fixed-bar-shadow);
|
||||
}
|
||||
|
||||
#fixed-bar.visible {
|
||||
@@ -93,13 +95,13 @@ const toggleVisibility = () => {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.fixed-stat span:first-child {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-line-container {
|
||||
@@ -108,7 +110,7 @@ const toggleVisibility = () => {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--progress-track-bg);
|
||||
}
|
||||
|
||||
.progress-line-fill {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup>
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
|
||||
function handleNewRandom() {
|
||||
// If currently custom, regenerate custom.
|
||||
// If not custom, switch to custom with current size?
|
||||
// Or maybe just re-init current level if it's not custom?
|
||||
// "NOWA LOSOWA" implies random.
|
||||
// If user is on Easy/Medium/Hard, "Random" might mean "Random predefined" or "Random generated".
|
||||
// Let's assume it generates a new random grid of current size.
|
||||
store.initCustomGame(store.size);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-actions">
|
||||
<button class="btn-neon secondary" @click="store.resetGame">RESET</button>
|
||||
<button class="btn-neon secondary" @click="handleNewRandom">NOWA LOSOWA</button>
|
||||
<button class="btn-neon secondary" @click="store.undo">COFNIJ</button>
|
||||
<button class="btn-neon secondary" @click="store.checkWin">SPRAWDŹ</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn-neon.secondary {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 25px;
|
||||
}
|
||||
|
||||
.btn-neon.secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,97 @@ const { startDrag, onMouseEnter, stopDrag } = useNonogram();
|
||||
|
||||
const cellSize = ref(30);
|
||||
const rowHintsRef = ref(null);
|
||||
const activeRow = ref(null);
|
||||
const activeCol = ref(null);
|
||||
const isFinePointer = ref(false);
|
||||
const scrollWrapper = ref(null);
|
||||
const scrollTrack = ref(null);
|
||||
const showScrollbar = ref(false);
|
||||
const thumbWidth = ref(20);
|
||||
const thumbLeft = ref(0);
|
||||
let isDraggingScroll = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartLeft = 0;
|
||||
|
||||
const checkScroll = () => {
|
||||
const el = scrollWrapper.value;
|
||||
if (!el) return;
|
||||
const sw = el.scrollWidth;
|
||||
const cw = el.clientWidth;
|
||||
|
||||
// Only show custom scrollbar on mobile/tablet (width < 768px) and if content overflows
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
showScrollbar.value = isMobile && (sw > cw + 1);
|
||||
|
||||
if (showScrollbar.value) {
|
||||
// Thumb width percentage = (viewport / total) * 100
|
||||
const ratio = cw / sw;
|
||||
thumbWidth.value = Math.max(10, ratio * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isDraggingScroll) return;
|
||||
const el = scrollWrapper.value;
|
||||
if (!el) return;
|
||||
const sw = el.scrollWidth;
|
||||
const cw = el.clientWidth;
|
||||
const sl = el.scrollLeft;
|
||||
const maxScroll = sw - cw;
|
||||
if (maxScroll <= 0) return;
|
||||
|
||||
// Map scroll position to thumb position (0 to 100 - thumbWidth)
|
||||
const maxThumb = 100 - thumbWidth.value;
|
||||
thumbLeft.value = (sl / maxScroll) * maxThumb;
|
||||
};
|
||||
|
||||
const startScrollDrag = (e) => {
|
||||
isDraggingScroll = true;
|
||||
dragStartX = e.clientX || e.touches[0].clientX;
|
||||
dragStartLeft = thumbLeft.value;
|
||||
|
||||
document.addEventListener('mousemove', onScrollDrag);
|
||||
document.addEventListener('mouseup', stopScrollDrag);
|
||||
document.addEventListener('touchmove', onScrollDrag, { passive: false });
|
||||
document.addEventListener('touchend', stopScrollDrag);
|
||||
};
|
||||
|
||||
const onScrollDrag = (e) => {
|
||||
if (!isDraggingScroll || !scrollTrack.value) return;
|
||||
|
||||
const clientX = e.clientX || (e.touches ? e.touches[0].clientX : 0);
|
||||
const deltaX = clientX - dragStartX;
|
||||
const trackWidth = scrollTrack.value.offsetWidth;
|
||||
|
||||
if (trackWidth === 0) return;
|
||||
|
||||
// Calculate delta as percentage of track
|
||||
const deltaPercent = (deltaX / trackWidth) * 100;
|
||||
|
||||
// New thumb position
|
||||
let newLeft = dragStartLeft + deltaPercent;
|
||||
const maxThumb = 100 - thumbWidth.value;
|
||||
newLeft = Math.max(0, Math.min(maxThumb, newLeft));
|
||||
|
||||
thumbLeft.value = newLeft;
|
||||
|
||||
// Sync scroll
|
||||
const el = scrollWrapper.value;
|
||||
if (el) {
|
||||
const sw = el.scrollWidth;
|
||||
const cw = el.clientWidth;
|
||||
const maxScroll = sw - cw;
|
||||
el.scrollLeft = (newLeft / maxThumb) * maxScroll;
|
||||
}
|
||||
};
|
||||
|
||||
const stopScrollDrag = () => {
|
||||
isDraggingScroll = false;
|
||||
document.removeEventListener('mousemove', onScrollDrag);
|
||||
document.removeEventListener('mouseup', stopScrollDrag);
|
||||
document.removeEventListener('touchmove', onScrollDrag);
|
||||
document.removeEventListener('touchend', stopScrollDrag);
|
||||
};
|
||||
|
||||
const getRowHintsWidth = () => {
|
||||
const el = rowHintsRef.value?.$el;
|
||||
@@ -20,17 +111,27 @@ const getRowHintsWidth = () => {
|
||||
};
|
||||
|
||||
const computeCellSize = () => {
|
||||
const vw = Math.min(window.innerWidth, 900);
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const hintWidth = getRowHintsWidth();
|
||||
const gapRaw = rootStyles.getPropertyValue('--gap-size') || '2px';
|
||||
const gridPadRaw = rootStyles.getPropertyValue('--grid-padding') || '5px';
|
||||
const gap = parseFloat(gapRaw);
|
||||
const gridPad = parseFloat(gridPadRaw);
|
||||
const bodyStyles = getComputedStyle(document.body);
|
||||
const bodyPadding = parseFloat(bodyStyles.paddingLeft) + parseFloat(bodyStyles.paddingRight);
|
||||
const availableForGrid = vw - bodyPadding - hintWidth;
|
||||
|
||||
let containerWidth;
|
||||
if (scrollWrapper.value) {
|
||||
containerWidth = scrollWrapper.value.clientWidth;
|
||||
} else {
|
||||
// Fallback if wrapper not ready: window width minus estimated padding
|
||||
// Body padding (40) + Layout padding (40) = 80
|
||||
containerWidth = window.innerWidth - 80;
|
||||
}
|
||||
|
||||
// Ensure we don't have negative space
|
||||
const availableForGrid = Math.max(0, containerWidth - hintWidth);
|
||||
|
||||
const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size);
|
||||
// Keep min 18, max 36
|
||||
cellSize.value = Math.max(18, Math.min(36, size));
|
||||
};
|
||||
|
||||
@@ -52,11 +153,26 @@ const handlePointerMove = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellEnter = (r, c) => {
|
||||
onMouseEnter(r, c);
|
||||
if (!isFinePointer.value) return;
|
||||
activeRow.value = r;
|
||||
activeCol.value = c;
|
||||
};
|
||||
|
||||
const handleGridLeave = () => {
|
||||
stopDrag();
|
||||
activeRow.value = null;
|
||||
activeCol.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
computeCellSize();
|
||||
});
|
||||
isFinePointer.value = window.matchMedia('(pointer: fine)').matches;
|
||||
window.addEventListener('resize', computeCellSize);
|
||||
window.addEventListener('resize', checkScroll);
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
window.addEventListener('pointerup', handleGlobalPointerUp);
|
||||
window.addEventListener('touchend', handleGlobalPointerUp, { passive: true });
|
||||
@@ -64,6 +180,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', computeCellSize);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
window.removeEventListener('pointerup', handleGlobalPointerUp);
|
||||
window.removeEventListener('touchend', handleGlobalPointerUp);
|
||||
@@ -72,19 +189,20 @@ onUnmounted(() => {
|
||||
watch(() => store.size, async () => {
|
||||
await nextTick();
|
||||
computeCellSize();
|
||||
checkScroll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-board-wrapper">
|
||||
<div class="game-board-wrapper" ref="scrollWrapper" @scroll="handleScroll">
|
||||
<div class="game-container" :style="{ '--cell-size': `${cellSize}px` }">
|
||||
<div class="corner-spacer"></div>
|
||||
|
||||
<!-- Column Hints -->
|
||||
<Hints :hints="colHints" orientation="col" :size="store.size" />
|
||||
<Hints :hints="colHints" orientation="col" :size="store.size" :activeIndex="activeCol" />
|
||||
|
||||
<!-- Row Hints -->
|
||||
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="store.size" />
|
||||
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="store.size" :activeIndex="activeRow" />
|
||||
|
||||
<!-- Grid -->
|
||||
<div
|
||||
@@ -94,7 +212,7 @@ watch(() => store.size, async () => {
|
||||
gridTemplateRows: `repeat(${store.size}, var(--cell-size))`
|
||||
}"
|
||||
@pointermove.prevent="handlePointerMove"
|
||||
@mouseleave="stopDrag"
|
||||
@mouseleave="handleGridLeave"
|
||||
>
|
||||
<template v-for="(row, r) in store.playerGrid" :key="r">
|
||||
<Cell
|
||||
@@ -108,19 +226,45 @@ watch(() => store.size, async () => {
|
||||
'guide-bottom': (r + 1) % 5 === 0 && r !== store.size - 1
|
||||
}"
|
||||
@start-drag="startDrag"
|
||||
@enter-cell="onMouseEnter"
|
||||
@enter-cell="handleCellEnter"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showScrollbar" class="fixed-scroll-bar">
|
||||
<div class="fixed-scroll-track" ref="scrollTrack" @pointerdown="startScrollDrag">
|
||||
<div
|
||||
class="fixed-scroll-thumb"
|
||||
:style="{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-board-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
scrollbar-width: none; /* Hide default scrollbar */
|
||||
}
|
||||
|
||||
/* Desktop: Remove scroll behavior to ensure full grid visibility */
|
||||
@media (min-width: 769px) {
|
||||
.game-board-wrapper {
|
||||
overflow-x: auto; /* Allow scrolling if grid is too large (e.g. 80x80) */
|
||||
align-items: center; /* Center the grid on desktop */
|
||||
}
|
||||
}
|
||||
|
||||
.game-board-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
@@ -128,17 +272,24 @@ watch(() => store.size, async () => {
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.3);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
margin-top: 10px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.corner-spacer {
|
||||
height: auto; /* Adapts to Col Hints height */
|
||||
}
|
||||
|
||||
/* Row Hints */
|
||||
.game-container > :nth-child(3) {
|
||||
/* No special styles */
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--gap-size);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useSolver } from '@/composables/useSolver';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
@@ -9,6 +10,7 @@ const {
|
||||
togglePlay,
|
||||
changeSpeed
|
||||
} = useSolver();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,15 +19,15 @@ const {
|
||||
|
||||
<div class="guide-controls">
|
||||
<button class="btn-neon small" @click="togglePlay" :class="{ active: isPlaying }">
|
||||
{{ isPlaying ? 'PAUSE' : 'PLAY' }}
|
||||
{{ isPlaying ? t('guide.pause') : t('guide.play') }}
|
||||
</button>
|
||||
|
||||
<button class="btn-neon small" @click="step" :disabled="isPlaying">
|
||||
STEP
|
||||
{{ t('guide.step') }}
|
||||
</button>
|
||||
|
||||
<button class="btn-neon small" @click="changeSpeed">
|
||||
SPEED: {{ speedLabel }}
|
||||
{{ t('guide.speed') }}: {{ speedLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,8 +35,8 @@ const {
|
||||
|
||||
<style scoped>
|
||||
.guide-panel {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(0, 242, 254, 0.3);
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
@@ -44,6 +46,7 @@ const {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
@@ -51,7 +54,7 @@ const {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
color: var(--text-muted);
|
||||
min-height: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
activeIndex: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -28,12 +32,13 @@ defineProps({
|
||||
v-for="(group, index) in hints"
|
||||
:key="index"
|
||||
class="hint-group"
|
||||
:class="{ 'hint-alt': index % 2 !== 0 }"
|
||||
:class="{ 'is-active': index === activeIndex }"
|
||||
>
|
||||
<span
|
||||
v-for="(num, idx) in group"
|
||||
:key="idx"
|
||||
class="hint-num"
|
||||
:class="{ 'hint-alt': idx % 2 !== 0 }"
|
||||
>
|
||||
{{ num }}
|
||||
</span>
|
||||
@@ -64,8 +69,8 @@ defineProps({
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--hint-bg);
|
||||
border: 1px solid var(--hint-border);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
@@ -85,19 +90,25 @@ defineProps({
|
||||
|
||||
.hint-num {
|
||||
font-size: 0.85rem;
|
||||
color: #fff;
|
||||
color: var(--text-strong);
|
||||
font-weight: bold;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Alternating Colors */
|
||||
.hint-group.hint-alt .hint-num {
|
||||
/* Alternating Colors within the group */
|
||||
.hint-num.hint-alt {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Hover effect for readability */
|
||||
.hint-group:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--hint-hover-bg);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.hint-group.is-active {
|
||||
background: rgba(79, 172, 254, 0.2);
|
||||
border-color: rgba(79, 172, 254, 0.8);
|
||||
box-shadow: 0 0 12px rgba(79, 172, 254, 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup>
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
|
||||
const levels = [
|
||||
{ id: 'easy', label: 'ŁATWY 5X5' },
|
||||
{ id: 'medium', label: 'ŚREDNI 10X10' },
|
||||
{ id: 'hard', label: 'TRUDNY 15X15' }
|
||||
];
|
||||
|
||||
const emit = defineEmits(['open-custom', 'toggle-guide']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="level-selector">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.id"
|
||||
class="btn-neon"
|
||||
:class="{ active: store.currentLevelId === lvl.id }"
|
||||
@click="store.initGame(lvl.id)"
|
||||
>
|
||||
{{ lvl.label }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-neon"
|
||||
:class="{ active: store.currentLevelId === 'custom' }"
|
||||
@click="emit('open-custom')"
|
||||
>
|
||||
WŁASNY
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-neon guide-btn"
|
||||
@click="emit('toggle-guide')"
|
||||
>
|
||||
GUIDE ❓
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.level-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-neon {
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.guide-btn {
|
||||
/* Specific styling for guide if needed */
|
||||
}
|
||||
</style>
|
||||
715
src/components/NavBar.vue
Normal file
@@ -0,0 +1,715 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor } from 'lucide-vue-next';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { t, locale, setLocale, locales } = useI18n();
|
||||
|
||||
const emit = defineEmits(['open-custom', 'toggle-guide', 'set-theme']);
|
||||
|
||||
const isGameOpen = ref(false);
|
||||
const isThemeOpen = ref(false);
|
||||
const isLangOpen = ref(false);
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const langMenuRef = ref(null);
|
||||
const searchTerm = ref('');
|
||||
|
||||
// Map language codes to country codes for flag-icons
|
||||
const langToCountry = {
|
||||
en: 'gb',
|
||||
zh: 'cn',
|
||||
'zh-hant': 'tw',
|
||||
hi: 'in',
|
||||
es: 'es',
|
||||
fr: 'fr',
|
||||
ar: 'sa',
|
||||
bn: 'bd',
|
||||
ru: 'ru',
|
||||
pt: 'pt',
|
||||
ur: 'pk',
|
||||
pl: 'pl',
|
||||
de: 'de',
|
||||
it: 'it',
|
||||
nl: 'nl',
|
||||
sv: 'se',
|
||||
da: 'dk',
|
||||
fi: 'fi',
|
||||
no: 'no',
|
||||
cs: 'cz',
|
||||
sk: 'sk',
|
||||
hu: 'hu',
|
||||
ro: 'ro',
|
||||
bg: 'bg',
|
||||
el: 'gr',
|
||||
uk: 'ua',
|
||||
be: 'by',
|
||||
sr: 'rs',
|
||||
hr: 'hr',
|
||||
sl: 'si',
|
||||
lt: 'lt',
|
||||
lv: 'lv',
|
||||
et: 'ee',
|
||||
ga: 'ie',
|
||||
is: 'is',
|
||||
mt: 'mt',
|
||||
sq: 'al',
|
||||
mk: 'mk',
|
||||
bs: 'ba',
|
||||
tr: 'tr',
|
||||
ca: 'es-ct',
|
||||
gl: 'es-ga',
|
||||
cy: 'gb-wls',
|
||||
gd: 'gb-sct',
|
||||
eu: 'es-pv',
|
||||
af: 'za',
|
||||
am: 'et',
|
||||
hy: 'am',
|
||||
az: 'az',
|
||||
my: 'mm',
|
||||
km: 'kh',
|
||||
ceb: 'ph',
|
||||
fa: 'ir',
|
||||
gu: 'in',
|
||||
ht: 'ht',
|
||||
he: 'il',
|
||||
ig: 'ng',
|
||||
ilo: 'ph',
|
||||
id: 'id',
|
||||
ja: 'jp',
|
||||
jv: 'id',
|
||||
kn: 'in',
|
||||
kk: 'kz',
|
||||
rw: 'rw',
|
||||
rn: 'bi',
|
||||
ko: 'kr',
|
||||
ku: 'tr',
|
||||
ckb: 'iq',
|
||||
ky: 'kg',
|
||||
lo: 'la',
|
||||
ms: 'my',
|
||||
mr: 'in',
|
||||
mn: 'mn',
|
||||
ne: 'np',
|
||||
om: 'et',
|
||||
ps: 'af',
|
||||
pa: 'in',
|
||||
so: 'so',
|
||||
sw: 'tz',
|
||||
tl: 'ph',
|
||||
ta: 'in',
|
||||
te: 'in',
|
||||
th: 'th',
|
||||
bo: 'cn',
|
||||
ti: 'er',
|
||||
uz: 'uz',
|
||||
vi: 'vn',
|
||||
wo: 'sn',
|
||||
yo: 'ng',
|
||||
'pt-br': 'br',
|
||||
'pt-pt': 'pt',
|
||||
'fr-ca': 'ca',
|
||||
'nl-be': 'be',
|
||||
'es-es': 'es',
|
||||
'es-419': 'mx',
|
||||
'zh-hant': 'tw',
|
||||
'zh-hans': 'cn'
|
||||
};
|
||||
|
||||
const getFlagClass = (code) => {
|
||||
const countryCode = langToCountry[code] || code;
|
||||
return `fi fi-${countryCode}`;
|
||||
};
|
||||
|
||||
const languages = computed(() => {
|
||||
return locales.value
|
||||
.map((code) => ({ code, label: t(`language.${code}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, locale.value));
|
||||
});
|
||||
|
||||
const filteredLanguages = computed(() => {
|
||||
const q = searchTerm.value.trim().toLowerCase();
|
||||
if (!q) return languages.value;
|
||||
return languages.value.filter((l) => l.label.toLowerCase().includes(q) || l.code.includes(q));
|
||||
});
|
||||
|
||||
const levels = computed(() => [
|
||||
{ id: 'easy', label: t('level.easy') },
|
||||
{ id: 'medium', label: t('level.medium') },
|
||||
{ id: 'hard', label: t('level.hard') }
|
||||
]);
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
isMobileMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleGameMenu = () => {
|
||||
isGameOpen.value = !isGameOpen.value;
|
||||
if (isGameOpen.value) {
|
||||
isThemeOpen.value = false;
|
||||
isLangOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleThemeMenu = () => {
|
||||
isThemeOpen.value = !isThemeOpen.value;
|
||||
if (isThemeOpen.value) {
|
||||
isGameOpen.value = false;
|
||||
isLangOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLangMenu = () => {
|
||||
isLangOpen.value = !isLangOpen.value;
|
||||
if (isLangOpen.value) {
|
||||
isGameOpen.value = false;
|
||||
isThemeOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectLevel = (id) => {
|
||||
store.initGame(id);
|
||||
isGameOpen.value = false;
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
const openCustom = () => {
|
||||
emit('open-custom');
|
||||
isGameOpen.value = false;
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
const setTheme = (theme) => {
|
||||
emit('set-theme', theme);
|
||||
isThemeOpen.value = false;
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
const selectLanguage = (value) => {
|
||||
setLocale(value);
|
||||
isLangOpen.value = false;
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
const toggleGuide = () => {
|
||||
emit('toggle-guide');
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
// Close menus when clicking outside
|
||||
const closeMenus = (e) => {
|
||||
if (isMobileMenuOpen.value) return; // Don't close desktop menus if mobile is open (handled separately)
|
||||
|
||||
if (!e.target.closest('.nav-dropdown')) {
|
||||
isGameOpen.value = false;
|
||||
isThemeOpen.value = false;
|
||||
isLangOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeMenus);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeMenus);
|
||||
});
|
||||
|
||||
// Watch mobile menu to lock body scroll
|
||||
watch(isMobileMenuOpen, (val) => {
|
||||
if (val) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="navbar">
|
||||
<div class="nav-left">
|
||||
<h1 class="app-title">{{ t('app.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Hamburger -->
|
||||
<button class="btn-neon nav-btn icon-only mobile-only" @click="isMobileMenuOpen = true">
|
||||
<Menu :size="24" />
|
||||
</button>
|
||||
|
||||
<div class="nav-container desktop-only">
|
||||
<!-- Game Menu -->
|
||||
<div class="nav-dropdown">
|
||||
<button class="btn-neon nav-btn" @click.stop="toggleGameMenu">
|
||||
<Gamepad2 :size="18" /> {{ t('nav.newGame') }}
|
||||
</button>
|
||||
<transition name="slide-fade">
|
||||
<div v-if="isGameOpen" class="dropdown-menu">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.id"
|
||||
class="dropdown-item"
|
||||
@click="selectLevel(lvl.id)"
|
||||
>
|
||||
{{ lvl.label }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="openCustom">
|
||||
{{ t('level.custom') }}
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Theme Menu -->
|
||||
<div class="nav-dropdown">
|
||||
<button class="btn-neon nav-btn" @click.stop="toggleThemeMenu">
|
||||
<Palette :size="18" /> {{ t('theme.label') }}
|
||||
</button>
|
||||
<transition name="slide-fade">
|
||||
<div v-if="isThemeOpen" class="dropdown-menu theme-menu">
|
||||
<button class="dropdown-item" @click="setTheme('system')">
|
||||
<Monitor :size="16" /> {{ t('theme.system') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="setTheme('light')">
|
||||
<Sun :size="16" /> {{ t('theme.light') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="setTheme('dark')">
|
||||
<Moon :size="16" /> {{ t('theme.dark') }}
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Guide Button -->
|
||||
<button class="btn-neon nav-btn" @click="toggleGuide">
|
||||
<CircleHelp :size="18" /> {{ t('nav.guide') }}
|
||||
</button>
|
||||
|
||||
<!-- Language Menu -->
|
||||
<div class="nav-dropdown">
|
||||
<button class="btn-neon nav-btn icon-only flag-btn" @click.stop="toggleLangMenu">
|
||||
<span class="lang-flag-current">
|
||||
<span :class="getFlagClass(locale)"></span>
|
||||
</span>
|
||||
</button>
|
||||
<transition name="slide-fade">
|
||||
<div v-if="isLangOpen" class="dropdown-menu lang-menu-dropdown">
|
||||
<div class="lang-search">
|
||||
<input
|
||||
class="lang-search-input"
|
||||
type="text"
|
||||
:placeholder="t('language.searchPlaceholder')"
|
||||
v-model="searchTerm"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<div class="lang-list">
|
||||
<button
|
||||
v-for="lang in filteredLanguages"
|
||||
:key="lang.code"
|
||||
class="dropdown-item"
|
||||
:class="{ active: locale === lang.code }"
|
||||
@click="selectLanguage(lang.code)"
|
||||
>
|
||||
<span class="lang-flag-item">
|
||||
<span :class="getFlagClass(lang.code)"></span>
|
||||
</span>
|
||||
<span class="lang-name">{{ lang.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Overlay -->
|
||||
<transition name="fade">
|
||||
<div v-if="isMobileMenuOpen" class="mobile-menu-overlay">
|
||||
<div class="mobile-menu-header">
|
||||
<h2 class="mobile-title">{{ t('app.title') }}</h2>
|
||||
<button class="btn-neon nav-btn icon-only close-btn" @click="closeMobileMenu">
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-menu-items">
|
||||
<!-- Mobile Game Menu -->
|
||||
<div class="mobile-group">
|
||||
<button class="mobile-item-trigger" @click="toggleGameMenu">
|
||||
<span class="flex-center gap-10"><Gamepad2 :size="20" /> {{ t('nav.newGame') }}</span>
|
||||
<component :is="isGameOpen ? ChevronUp : ChevronDown" :size="16" />
|
||||
</button>
|
||||
<div v-if="isGameOpen" class="mobile-sub-menu">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.id"
|
||||
class="mobile-sub-item"
|
||||
@click="selectLevel(lvl.id)"
|
||||
>
|
||||
{{ lvl.label }}
|
||||
</button>
|
||||
<button class="mobile-sub-item" @click="openCustom">
|
||||
{{ t('level.custom') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Theme Menu -->
|
||||
<div class="mobile-group">
|
||||
<button class="mobile-item-trigger" @click="toggleThemeMenu">
|
||||
<span class="flex-center gap-10"><Palette :size="20" /> {{ t('theme.label') }}</span>
|
||||
<component :is="isThemeOpen ? ChevronUp : ChevronDown" :size="16" />
|
||||
</button>
|
||||
<div v-if="isThemeOpen" class="mobile-sub-menu">
|
||||
<button class="mobile-sub-item" @click="setTheme('system')">
|
||||
<Monitor :size="16" /> {{ t('theme.system') }}
|
||||
</button>
|
||||
<button class="mobile-sub-item" @click="setTheme('light')">
|
||||
<Sun :size="16" /> {{ t('theme.light') }}
|
||||
</button>
|
||||
<button class="mobile-sub-item" @click="setTheme('dark')">
|
||||
<Moon :size="16" /> {{ t('theme.dark') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Guide -->
|
||||
<div class="mobile-group">
|
||||
<button class="mobile-item-trigger" @click="toggleGuide">
|
||||
<span class="flex-center gap-10"><CircleHelp :size="20" /> {{ t('nav.guide') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Language -->
|
||||
<div class="mobile-group">
|
||||
<button class="mobile-item-trigger" @click="toggleLangMenu">
|
||||
<span class="flex-center gap-10">
|
||||
<span class="lang-flag-current mobile-flag">
|
||||
<span :class="getFlagClass(locale)"></span>
|
||||
</span>
|
||||
{{ t('language.label') }}
|
||||
</span>
|
||||
<component :is="isLangOpen ? ChevronUp : ChevronDown" :size="16" />
|
||||
</button>
|
||||
<div v-if="isLangOpen" class="mobile-sub-menu">
|
||||
<div class="lang-search mobile-search">
|
||||
<input
|
||||
class="lang-search-input"
|
||||
type="text"
|
||||
:placeholder="t('language.searchPlaceholder')"
|
||||
v-model="searchTerm"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<div class="lang-list mobile-lang-list">
|
||||
<button
|
||||
v-for="lang in filteredLanguages"
|
||||
:key="lang.code"
|
||||
class="mobile-sub-item"
|
||||
:class="{ active: locale === lang.code }"
|
||||
@click="selectLanguage(lang.code)"
|
||||
>
|
||||
<span class="lang-flag-item">
|
||||
<span :class="getFlagClass(lang.code)"></span>
|
||||
</span>
|
||||
<span class="lang-name">{{ lang.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
width: 100%;
|
||||
padding: 15px 30px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(0, 242, 254, 0.1);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
letter-spacing: 3px;
|
||||
font-weight: 300;
|
||||
color: var(--text-strong);
|
||||
text-shadow: 0 0 10px var(--title-glow);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.95rem;
|
||||
min-width: 120px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-btn.icon-only {
|
||||
min-width: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.nav-btn.flag-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-btn.flag-btn:hover {
|
||||
background: transparent;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.lang-flag-current img, .lang-flag-current svg {
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-btn.flag-btn:hover .lang-flag-current img,
|
||||
.nav-btn.flag-btn:hover .lang-flag-current svg {
|
||||
box-shadow: 0 0 15px var(--primary-neon);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 10px;
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
border: 1px solid var(--primary-neon);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lang-menu-dropdown {
|
||||
width: 250px;
|
||||
right: 0;
|
||||
left: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lang-search {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.lang-search-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-item:hover, .dropdown-item.active {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
color: var(--primary-neon);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.lang-flag-current img, .lang-flag-item img {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lang-flag-current svg, .lang-flag-item svg {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Helper */
|
||||
.flex-center { display: flex; align-items: center; }
|
||||
.gap-10 { gap: 10px; }
|
||||
|
||||
/* Desktop/Mobile Visibility */
|
||||
.desktop-only { display: flex; }
|
||||
.mobile-only { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only { display: none; }
|
||||
.mobile-only { display: flex; }
|
||||
|
||||
.navbar {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Overlay */
|
||||
.mobile-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba(10, 10, 25, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid rgba(0, 242, 254, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 1.8rem;
|
||||
color: var(--primary-neon);
|
||||
margin: 0;
|
||||
letter-spacing: 3px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.mobile-menu-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mobile-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-item-trigger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
padding: 15px 0;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.mobile-sub-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-left: 15px;
|
||||
padding-bottom: 10px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.mobile-sub-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mobile-sub-item:hover, .mobile-sub-item.active {
|
||||
color: var(--primary-neon);
|
||||
background: rgba(0, 242, 254, 0.1);
|
||||
}
|
||||
|
||||
.mobile-flag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
110
src/components/ReloadPrompt.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
offlineReady,
|
||||
needRefresh,
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW()
|
||||
|
||||
const close = async () => {
|
||||
offlineReady.value = false
|
||||
needRefresh.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="offlineReady || needRefresh"
|
||||
class="pwa-toast"
|
||||
role="alert"
|
||||
>
|
||||
<div class="message">
|
||||
<span v-if="offlineReady">
|
||||
{{ t('pwa.offlineReady') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('pwa.newContent') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button v-if="needRefresh" class="btn-neon small" @click="updateServiceWorker()">
|
||||
{{ t('pwa.reload') }}
|
||||
</button>
|
||||
<button class="close-btn" @click="close">
|
||||
{{ t('pwa.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pwa-toast {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 60px; /* Above the footer */
|
||||
margin: 16px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--banner-border);
|
||||
background: var(--banner-bg);
|
||||
border-radius: 12px;
|
||||
z-index: 2000;
|
||||
text-align: left;
|
||||
box-shadow: var(--banner-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--text-color);
|
||||
max-width: 320px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
border-color: var(--text-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn-neon.small {
|
||||
padding: 6px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,12 @@
|
||||
import { computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
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));
|
||||
const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
@@ -12,22 +15,38 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
|
||||
<template>
|
||||
<div class="status-panel glass-panel">
|
||||
<div class="stat-item">
|
||||
<span class="label">CZAS</span>
|
||||
<span class="value">{{ formattedTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<span class="label">RUCHY</span>
|
||||
<span class="value">{{ store.moves }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<span class="label">POSTĘP</span>
|
||||
<div class="progress-wrapper">
|
||||
<span class="value small">{{ progressText }}</span>
|
||||
<span class="eye-icon">👁️</span>
|
||||
<div class="stats-group">
|
||||
<div class="stat-item">
|
||||
<span class="label">{{ t('status.time') }}</span>
|
||||
<span class="value">{{ formattedTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<span class="label">{{ t('status.moves') }}</span>
|
||||
<span class="value">{{ store.moves }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<span class="label">{{ t('status.progress') }}</span>
|
||||
<div class="progress-wrapper">
|
||||
<span class="value small">{{ progressText }}</span>
|
||||
<button class="eye-btn" :title="t('status.progress')">
|
||||
<Eye :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-separator"></div>
|
||||
|
||||
<div class="actions-group">
|
||||
<button class="action-btn" @click="store.resetGame" :title="t('actions.reset')">
|
||||
<RefreshCw :size="20" />
|
||||
</button>
|
||||
<div class="action-separator"></div>
|
||||
<button class="action-btn" @click="store.undo" :title="t('actions.undo')">
|
||||
<Undo :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,17 +54,27 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
<style scoped>
|
||||
.status-panel {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 20px 40px;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: var(--panel-shadow);
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-width: 650px;
|
||||
overflow: hidden;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.stats-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
@@ -58,13 +87,13 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.8rem;
|
||||
color: #fff;
|
||||
color: var(--text-strong);
|
||||
font-weight: 300;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
@@ -79,8 +108,56 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
.eye-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-strong);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.eye-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--primary-neon);
|
||||
}
|
||||
|
||||
.panel-separator {
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--primary-neon);
|
||||
}
|
||||
|
||||
.action-separator {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
width: 80%;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,23 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
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 { Download } 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));
|
||||
const shareText = computed(() => t('win.shareText', { size: store.size, time: formattedTime.value }));
|
||||
|
||||
const handleClose = () => {
|
||||
store.closeWinModal();
|
||||
@@ -17,6 +29,245 @@ 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.18;
|
||||
masterGain.connect(audioContext.destination);
|
||||
const notes = [
|
||||
{ time: 0.0, dur: 0.18, freqs: [523.25, 659.25, 783.99] },
|
||||
{ time: 0.2, dur: 0.18, freqs: [587.33, 740.0, 880.0] },
|
||||
{ time: 0.4, dur: 0.22, freqs: [659.25, 830.61, 987.77] },
|
||||
{ time: 0.7, dur: 0.35, freqs: [698.46, 880.0, 1046.5] }
|
||||
];
|
||||
const now = audioContext.currentTime;
|
||||
notes.forEach(({ time, dur, freqs }) => {
|
||||
freqs.forEach((freq) => {
|
||||
const osc = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.0001, now + time);
|
||||
gain.gain.linearRampToValueAtTime(0.8, now + time + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + time + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(now + time);
|
||||
osc.stop(now + time + dur + 0.05);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const triggerVibration = () => {
|
||||
if (!('vibrate' in navigator)) return;
|
||||
const isCoarse = window.matchMedia?.('(pointer: coarse)')?.matches;
|
||||
const isTouch = navigator.maxTouchPoints && navigator.maxTouchPoints > 0;
|
||||
if (isCoarse || isTouch) {
|
||||
navigator.vibrate([80, 40, 120, 40, 180]);
|
||||
}
|
||||
};
|
||||
|
||||
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 guideText = t('win.usedGuide', { count: store.guideUsageCount });
|
||||
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 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 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 '';
|
||||
};
|
||||
|
||||
const shareTo = async (target) => {
|
||||
if (shareInProgress.value) return;
|
||||
shareInProgress.value = true;
|
||||
|
||||
const text = shareText.value;
|
||||
const url = window.location.href;
|
||||
const shareUrl = buildShareUrl(target, text, url);
|
||||
|
||||
try {
|
||||
// Try native share first if available (supports images)
|
||||
if (navigator.share && navigator.canShare) {
|
||||
const blob = await createShareBlob();
|
||||
if (blob) {
|
||||
const file = new File([blob], `nonogram-${store.size}x${store.size}.png`, { type: 'image/png' });
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
text,
|
||||
title: t('app.title'),
|
||||
url
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return; // User cancelled native share, do nothing
|
||||
}
|
||||
// Other errors -> fall through to fallback
|
||||
} finally {
|
||||
shareInProgress.value = false;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Trigger download as "screenshot support"
|
||||
downloadShareImage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (fireworksRef.value) {
|
||||
fireworksInstance = new Fireworks(fireworksRef.value, {
|
||||
@@ -37,6 +288,8 @@ onMounted(() => {
|
||||
});
|
||||
fireworksInstance.start();
|
||||
}
|
||||
playFanfare();
|
||||
triggerVibration();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
@@ -44,6 +297,14 @@ onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
fireworksInstance?.stop(true);
|
||||
fireworksInstance = null;
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(0);
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
masterGain = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -51,18 +312,40 @@ onUnmounted(() => {
|
||||
<div class="modal-overlay" @click.self="handleClose">
|
||||
<div ref="fireworksRef" class="fireworks-layer"></div>
|
||||
<div class="modal glass-panel">
|
||||
<h2>GRATULACJE!</h2>
|
||||
<p>Rozwiązałeś zagadkę!</p>
|
||||
<h2>{{ t('win.title') }}</h2>
|
||||
<p>{{ t('win.message') }}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span>Czas:</span>
|
||||
<strong>{{ store.elapsedTime }}s</strong>
|
||||
<span>{{ t('win.time') }}</span>
|
||||
<strong>{{ formattedTime }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share">
|
||||
<div class="share-title">{{ t('win.shareTitle') }}</div>
|
||||
<div class="share-buttons">
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- Download Screenshot (Compact) -->
|
||||
<button class="btn-neon secondary share-btn" :disabled="shareInProgress" :aria-label="t('win.shareDownload')" @click="downloadShareImage">
|
||||
<Download :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-neon" @click="store.resetGame">Zagraj Ponownie</button>
|
||||
<button class="btn-neon" @click="store.resetGame">{{ t('win.playAgain') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,7 +358,7 @@ onUnmounted(() => {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: var(--modal-overlay);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -96,13 +379,15 @@ onUnmounted(() => {
|
||||
.modal {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
width: fit-content;
|
||||
max-width: min(92vw, 560px);
|
||||
min-width: 280px;
|
||||
border: 1px solid var(--primary-accent);
|
||||
box-shadow: 0 0 50px rgba(0, 242, 255, 0.2);
|
||||
animation: slideUp 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -110,18 +395,20 @@ h2 {
|
||||
color: var(--primary-accent);
|
||||
margin: 0 0 10px 0;
|
||||
text-shadow: 0 0 20px var(--primary-accent);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 30px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--panel-bg-strong);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -130,10 +417,62 @@ p {
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
color: #fff;
|
||||
color: var(--text-strong);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.share {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.share-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.share-download {
|
||||
align-self: center;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.85rem;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions .btn-neon {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
5220
src/composables/useI18n.js
Normal file
@@ -8,7 +8,7 @@ export function useNonogram() {
|
||||
const dragMode = ref(null); // 'fill', 'empty', 'cross'
|
||||
const startCellState = ref(null);
|
||||
|
||||
const startDrag = (r, c, isRightClick = false) => {
|
||||
const startDrag = (r, c, isRightClick = false, force = false) => {
|
||||
if (store.isGameWon) return;
|
||||
|
||||
isDragging.value = true;
|
||||
@@ -16,9 +16,7 @@ export function useNonogram() {
|
||||
|
||||
if (isRightClick) {
|
||||
// Right click logic
|
||||
// If current is 1 (filled), do nothing usually? Or ignore?
|
||||
// Standard: Toggle 0 <-> 2
|
||||
if (current === 1) {
|
||||
if (!force && current === 1) {
|
||||
dragMode.value = null; // invalid drag start
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,174 +1,37 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { calculateHints } from '@/utils/puzzleUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export function useSolver() {
|
||||
const store = usePuzzleStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const speedIndex = ref(0);
|
||||
const speeds = [1000, 500, 250, 125];
|
||||
const speedLabels = ['x1', 'x2', 'x3', 'x4'];
|
||||
const statusText = ref('Oczekiwanie...');
|
||||
const statusText = ref(t('guide.waiting'));
|
||||
|
||||
let intervalId = null;
|
||||
|
||||
// --- Core Solver Logic (Human-Like) ---
|
||||
|
||||
// Generate all valid line permutations
|
||||
function getPermutations(length, hints) {
|
||||
const results = [];
|
||||
|
||||
function recurse(index, hintIndex, currentLine) {
|
||||
// Base case: all hints placed
|
||||
if (hintIndex === hints.length) {
|
||||
// Fill rest with 0
|
||||
while (currentLine.length < length) {
|
||||
currentLine.push(0);
|
||||
}
|
||||
results.push(currentLine);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHint = hints[hintIndex];
|
||||
// Calculate remaining space needed for other hints + gaps
|
||||
let remainingHintsLen = 0;
|
||||
for (let i = hintIndex + 1; i < hints.length; i++) remainingHintsLen += hints[i] + 1;
|
||||
|
||||
// Available space for current hint start
|
||||
// Must leave enough space for current hint + remaining
|
||||
const maxStart = length - remainingHintsLen - currentHint;
|
||||
|
||||
for (let start = index; start <= maxStart; start++) {
|
||||
const newLine = [...currentLine];
|
||||
// Add padding 0s before hint
|
||||
for (let k = index; k < start; k++) newLine.push(0);
|
||||
// Add hint 1s
|
||||
for (let k = 0; k < currentHint; k++) newLine.push(1);
|
||||
// Add gap 0 if not last hint
|
||||
if (hintIndex < hints.length - 1) newLine.push(0);
|
||||
|
||||
recurse(newLine.length, hintIndex + 1, newLine);
|
||||
}
|
||||
}
|
||||
|
||||
recurse(0, 0, []);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Filter permutations that match current known state (0=empty, 1=filled, 2=cross/empty-known)
|
||||
// Note: In our store: 0=empty(unknown), 1=filled, 2=cross(known empty).
|
||||
// In logic: 0=empty, 1=filled.
|
||||
// So if board has 1, perm must have 1. If board has 2, perm must have 0.
|
||||
function isValidPermutation(perm, currentLineState) {
|
||||
for (let i = 0; i < perm.length; i++) {
|
||||
const boardVal = currentLineState[i];
|
||||
const permVal = perm[i];
|
||||
|
||||
if (boardVal === 1 && permVal !== 1) return false; // Must be filled
|
||||
if (boardVal === 2 && permVal !== 0) return false; // Must be empty
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function solveLineLogic(lineState, hints, size) {
|
||||
// 1. Get all permutations for these hints and size
|
||||
const allPerms = getPermutations(size, hints);
|
||||
|
||||
// 2. Filter by current board state
|
||||
const validPerms = allPerms.filter(p => isValidPermutation(p, lineState));
|
||||
|
||||
if (validPerms.length === 0) return { index: -1 }; // Conflict or error
|
||||
|
||||
// 3. Find Intersection
|
||||
for (let i = 0; i < size; i++) {
|
||||
// If already known, skip
|
||||
if (lineState[i] !== 0) continue;
|
||||
|
||||
let allOne = true;
|
||||
let allZero = true;
|
||||
|
||||
for (const p of validPerms) {
|
||||
if (p[i] === 0) allOne = false;
|
||||
if (p[i] === 1) allZero = false;
|
||||
if (!allOne && !allZero) break;
|
||||
}
|
||||
|
||||
if (allOne) return { index: i, state: 1 }; // Must be filled
|
||||
if (allZero) return { index: i, state: 2 }; // Must be empty (cross)
|
||||
}
|
||||
|
||||
return { index: -1 };
|
||||
}
|
||||
let worker = null;
|
||||
let requestId = 0;
|
||||
|
||||
function step() {
|
||||
if (store.isGameWon) {
|
||||
pause();
|
||||
statusText.value = "Rozwiązane!";
|
||||
statusText.value = t('guide.solved');
|
||||
return;
|
||||
}
|
||||
if (isProcessing.value) return;
|
||||
store.markGuideUsed();
|
||||
ensureWorker();
|
||||
isProcessing.value = true;
|
||||
|
||||
const size = store.size;
|
||||
const { rowHints, colHints } = calculateHints(store.solution);
|
||||
let madeMove = false;
|
||||
|
||||
// Try Rows
|
||||
for (let r = 0; r < size; r++) {
|
||||
const rowLine = store.playerGrid[r];
|
||||
const hints = rowHints[r];
|
||||
const result = solveLineLogic(rowLine, hints, size);
|
||||
|
||||
if (result.index !== -1) {
|
||||
store.setCell(r, result.index, result.state);
|
||||
statusText.value = `Logika: Wiersz ${r+1}, Kolumna ${result.index+1} -> ${result.state === 1 ? 'Pełne' : 'Puste'}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try Cols
|
||||
for (let c = 0; c < size; c++) {
|
||||
const colLine = [];
|
||||
for(let r=0; r<size; r++) colLine.push(store.playerGrid[r][c]);
|
||||
|
||||
const hints = colHints[c];
|
||||
const result = solveLineLogic(colLine, hints, size);
|
||||
|
||||
if (result.index !== -1) {
|
||||
store.setCell(result.index, c, result.state);
|
||||
statusText.value = `Logika: Kolumna ${c+1}, Wiersz ${result.index+1} -> ${result.state === 1 ? 'Pełne' : 'Puste'}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart Guess (Fallback)
|
||||
// Find a cell that is 1 in solution but 0 in grid (or 0 in solution and 0 in grid)
|
||||
// This is "Cheating" but ensures progress if logic gets stuck (or for large grids where logic is slow)
|
||||
// Real solver would branch, but for this app we use "Oracle Guess" if logic fails.
|
||||
if (!madeMove) {
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const current = store.playerGrid[r][c];
|
||||
const target = store.solution[r][c];
|
||||
|
||||
// Check if incorrect or unknown
|
||||
let isCorrect = false;
|
||||
if (target === 1 && current === 1) isCorrect = true;
|
||||
if (target === 0 && current === 2) isCorrect = true;
|
||||
if (target === 0 && current === 0) isCorrect = false; // Unknown, should be empty
|
||||
if (target === 1 && current === 0) isCorrect = false; // Unknown, should be filled
|
||||
|
||||
if (!isCorrect) {
|
||||
const newState = (target === 1) ? 1 : 2;
|
||||
store.setCell(r, c, newState);
|
||||
statusText.value = `Zgadywanie: Wiersz ${r+1}, Kolumna ${c+1}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If here, puzzle is solved
|
||||
statusText.value = "Koniec!";
|
||||
pause();
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
@@ -199,6 +62,37 @@ export function useSolver() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker) return;
|
||||
worker = new Worker(new URL('../workers/solverWorker.js', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event) => {
|
||||
const { type, r, c, state, statusText: text } = event.data;
|
||||
if (text) statusText.value = text;
|
||||
if (type === 'move') {
|
||||
store.setCell(r, c, state);
|
||||
isProcessing.value = false;
|
||||
if (store.isGameWon) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
} else if (type === 'done') {
|
||||
isProcessing.value = false;
|
||||
pause();
|
||||
} else if (type === 'stuck') {
|
||||
isProcessing.value = false;
|
||||
pause();
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
pause();
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
});
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
speedIndex,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import '/node_modules/flag-icons/css/flag-icons.min.css'
|
||||
import './styles/main.css'
|
||||
|
||||
// Custom directive v-cell-hover (zgodnie z wymaganiami)
|
||||
@@ -29,3 +30,4 @@ app.use(createPinia())
|
||||
app.directive('cell-hover', vCellHover)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
const solution = ref([]);
|
||||
const playerGrid = ref([]); // 0: empty, 1: filled, 2: cross
|
||||
const isGameWon = ref(false);
|
||||
const hasUsedGuide = ref(false);
|
||||
const guideUsageCount = ref(0);
|
||||
const currentDifficulty = ref(null); // 'easy', 'medium', 'hard', 'custom' or object { density: 0.5 }
|
||||
const currentDensity = ref(0);
|
||||
const size = ref(5);
|
||||
const startTime = ref(null);
|
||||
const elapsedTime = ref(0);
|
||||
@@ -116,22 +120,30 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
currentDensity.value = totalCellsToFill.value / (size.value * size.value);
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function initCustomGame(customSize) {
|
||||
function initCustomGame(customSize, density = 0.5) {
|
||||
stopTimer();
|
||||
currentLevelId.value = 'custom';
|
||||
size.value = customSize;
|
||||
|
||||
// Generate random grid
|
||||
solution.value = generateRandomGrid(customSize);
|
||||
solution.value = generateRandomGrid(customSize, density);
|
||||
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
currentDensity.value = density;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function resetGrid() {
|
||||
@@ -238,6 +250,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
solution: solution.value,
|
||||
playerGrid: playerGrid.value,
|
||||
isGameWon: isGameWon.value,
|
||||
hasUsedGuide: hasUsedGuide.value,
|
||||
guideUsageCount: guideUsageCount.value,
|
||||
currentDensity: currentDensity.value,
|
||||
elapsedTime: elapsedTime.value,
|
||||
moves: moves.value,
|
||||
history: history.value
|
||||
@@ -255,6 +270,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
solution.value = parsed.solution;
|
||||
playerGrid.value = parsed.playerGrid;
|
||||
isGameWon.value = parsed.isGameWon;
|
||||
hasUsedGuide.value = parsed.hasUsedGuide || false;
|
||||
guideUsageCount.value = parsed.guideUsageCount || 0;
|
||||
currentDensity.value = parsed.currentDensity || 0;
|
||||
elapsedTime.value = parsed.elapsedTime || 0;
|
||||
moves.value = parsed.moves || 0;
|
||||
history.value = parsed.history || [];
|
||||
@@ -271,44 +289,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
function initGame(levelId = 'easy') {
|
||||
// If init called without args and we have save, load it?
|
||||
// User might want to start fresh if clicking buttons.
|
||||
// Let's add explicit 'continue' logic or just auto-load on first run.
|
||||
// For now, let's just stick to explicit init, but maybe load on mount if exists?
|
||||
// The user didn't explicitly ask for "Continue", but "features from HTML".
|
||||
// HTML usually auto-saves and loads.
|
||||
|
||||
stopTimer();
|
||||
currentLevelId.value = levelId;
|
||||
|
||||
let puzzle = PUZZLES[levelId];
|
||||
if (!puzzle) {
|
||||
puzzle = PUZZLES['easy'];
|
||||
}
|
||||
|
||||
size.value = puzzle.size;
|
||||
solution.value = puzzle.grid;
|
||||
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
}
|
||||
// Duplicate initGame removed
|
||||
|
||||
// Modify initCustomGame similarly
|
||||
function initCustomGame(customSize) {
|
||||
stopTimer();
|
||||
currentLevelId.value = 'custom';
|
||||
size.value = customSize;
|
||||
solution.value = generateRandomGrid(customSize);
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
}
|
||||
// Duplicate initCustomGame removed
|
||||
|
||||
// Duplicate toggleCell/setCell removed
|
||||
|
||||
@@ -316,6 +299,8 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
if (currentLevelId.value === 'custom') {
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
hasUsedGuide.value = false;
|
||||
guideUsageCount.value = 0;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
@@ -324,6 +309,13 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function markGuideUsed() {
|
||||
if (isGameWon.value) return;
|
||||
hasUsedGuide.value = true;
|
||||
guideUsageCount.value++;
|
||||
saveState();
|
||||
}
|
||||
|
||||
function closeWinModal() {
|
||||
if (!isGameWon.value) return;
|
||||
isGameWon.value = false;
|
||||
@@ -347,7 +339,11 @@ export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
loadState, // expose loadState
|
||||
moves,
|
||||
undo,
|
||||
closeWinModal
|
||||
closeWinModal,
|
||||
hasUsedGuide,
|
||||
guideUsageCount,
|
||||
currentDensity,
|
||||
markGuideUsed
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './scrollbar.css';
|
||||
|
||||
:root {
|
||||
/* --- Glassmorphism Design System --- */
|
||||
--bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%);
|
||||
@@ -5,12 +7,56 @@
|
||||
--glass-border: rgba(255, 255, 255, 0.2);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
--text-color: #ffffff;
|
||||
--text-strong: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.85);
|
||||
--text-muted: rgba(255, 255, 255, 0.7);
|
||||
--accent-cyan: #00f2fe;
|
||||
--accent-purple: #4facfe;
|
||||
--primary-accent: #00f2fe;
|
||||
--cell-empty: rgba(255, 255, 255, 0.05);
|
||||
--cell-hover: rgba(255, 255, 255, 0.2);
|
||||
--cell-filled-gradient: linear-gradient(45deg, #00f2fe, #4facfe);
|
||||
--cell-x-color: rgba(255, 255, 255, 0.4);
|
||||
--title-glow: rgba(0, 255, 255, 0.2);
|
||||
--toggle-bg: rgba(255, 255, 255, 0.08);
|
||||
--toggle-border: rgba(255, 255, 255, 0.2);
|
||||
--toggle-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
|
||||
--toggle-btn-border: rgba(255, 255, 255, 0.2);
|
||||
--toggle-hover-border: #ffffff;
|
||||
--toggle-active-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
|
||||
--banner-bg: rgba(0, 0, 0, 0.35);
|
||||
--banner-border: rgba(0, 242, 254, 0.35);
|
||||
--banner-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
--panel-bg: rgba(255, 255, 255, 0.1);
|
||||
--panel-border: rgba(255, 255, 255, 0.1);
|
||||
--panel-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
--panel-bg-strong: rgba(0, 0, 0, 0.3);
|
||||
--modal-overlay: rgba(0, 0, 0, 0.7);
|
||||
--fixed-bar-bg: rgba(0, 0, 0, 0.85);
|
||||
--fixed-bar-border: rgba(255, 255, 255, 0.1);
|
||||
--fixed-bar-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
--progress-track-bg: rgba(255, 255, 255, 0.1);
|
||||
--hint-bg: rgba(255, 255, 255, 0.05);
|
||||
--hint-border: rgba(255, 255, 255, 0.1);
|
||||
--hint-hover-bg: rgba(255, 255, 255, 0.1);
|
||||
--button-bg: rgba(255, 255, 255, 0.1);
|
||||
--button-border: rgba(255, 255, 255, 0.2);
|
||||
--button-text: #ffffff;
|
||||
--button-hover-bg: rgba(255, 255, 255, 0.25);
|
||||
--button-hover-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
--button-active-shadow: 0 0 20px rgba(79, 172, 254, 0.4);
|
||||
--button-secondary-bg: rgba(0, 0, 0, 0.2);
|
||||
--button-secondary-border: rgba(255, 255, 255, 0.1);
|
||||
--button-secondary-text: rgba(255, 255, 255, 0.8);
|
||||
--button-secondary-hover-bg: rgba(255, 255, 255, 0.1);
|
||||
--button-secondary-hover-border: #ffffff;
|
||||
--button-secondary-hover-text: #ffffff;
|
||||
--scroll-track: rgba(0, 0, 0, 0.1);
|
||||
--scroll-thumb: rgba(255, 255, 255, 0.2);
|
||||
--scroll-thumb-hover: var(--accent-cyan);
|
||||
--fixed-bar-bg: rgba(0, 0, 0, 0.85);
|
||||
--fixed-bar-thumb: rgba(0, 242, 255, 0.5);
|
||||
--fixed-bar-thumb-hover: rgba(0, 242, 255, 0.8);
|
||||
|
||||
/* Rozmiary */
|
||||
--cell-size: 30px;
|
||||
@@ -19,12 +65,75 @@
|
||||
--grid-padding: 5px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg-gradient: linear-gradient(135deg, #f7fbff 0%, #dde7ff 100%);
|
||||
--glass-bg: rgba(255, 255, 255, 0.75);
|
||||
--glass-border: rgba(15, 23, 42, 0.12);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12);
|
||||
--text-color: #0f172a;
|
||||
--text-strong: #0f172a;
|
||||
--text-secondary: rgba(15, 23, 42, 0.7);
|
||||
--text-muted: rgba(15, 23, 42, 0.6);
|
||||
--accent-cyan: #0ea5e9;
|
||||
--accent-purple: #6366f1;
|
||||
--primary-accent: #0ea5e9;
|
||||
--cell-empty: rgba(15, 23, 42, 0.05);
|
||||
--cell-hover: rgba(15, 23, 42, 0.12);
|
||||
--cell-filled-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
|
||||
--cell-x-color: rgba(15, 23, 42, 0.35);
|
||||
--title-glow: rgba(14, 165, 233, 0.35);
|
||||
--toggle-bg: rgba(255, 255, 255, 0.85);
|
||||
--toggle-border: rgba(15, 23, 42, 0.12);
|
||||
--toggle-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
--toggle-btn-border: rgba(15, 23, 42, 0.18);
|
||||
--toggle-hover-border: rgba(15, 23, 42, 0.5);
|
||||
--toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
|
||||
--banner-bg: rgba(255, 255, 255, 0.8);
|
||||
--banner-border: rgba(14, 165, 233, 0.35);
|
||||
--banner-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
|
||||
--panel-bg: rgba(255, 255, 255, 0.7);
|
||||
--panel-border: rgba(15, 23, 42, 0.12);
|
||||
--panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
|
||||
--panel-bg-strong: rgba(15, 23, 42, 0.08);
|
||||
--modal-overlay: rgba(15, 23, 42, 0.45);
|
||||
--fixed-bar-bg: rgba(248, 250, 255, 0.95);
|
||||
--fixed-bar-border: rgba(15, 23, 42, 0.12);
|
||||
--fixed-bar-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
--progress-track-bg: rgba(15, 23, 42, 0.08);
|
||||
--hint-bg: rgba(255, 255, 255, 0.7);
|
||||
--hint-border: rgba(15, 23, 42, 0.12);
|
||||
--hint-hover-bg: rgba(15, 23, 42, 0.06);
|
||||
--button-bg: rgba(255, 255, 255, 0.85);
|
||||
--button-border: rgba(15, 23, 42, 0.16);
|
||||
--button-text: #0f172a;
|
||||
--button-hover-bg: rgba(255, 255, 255, 1);
|
||||
--button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
|
||||
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
|
||||
--button-secondary-bg: rgba(15, 23, 42, 0.06);
|
||||
--button-secondary-border: rgba(15, 23, 42, 0.2);
|
||||
--button-secondary-text: rgba(15, 23, 42, 0.8);
|
||||
--button-secondary-hover-bg: rgba(15, 23, 42, 0.12);
|
||||
--button-secondary-hover-border: rgba(15, 23, 42, 0.5);
|
||||
--button-secondary-hover-text: #0f172a;
|
||||
--scroll-track: rgba(15, 23, 42, 0.08);
|
||||
--scroll-thumb: rgba(15, 23, 42, 0.2);
|
||||
--scroll-thumb-hover: #0ea5e9;
|
||||
--fixed-bar-bg: rgba(248, 250, 255, 0.95);
|
||||
--fixed-bar-thumb: rgba(14, 165, 233, 0.5);
|
||||
--fixed-bar-thumb-hover: rgba(14, 165, 233, 0.8);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
@@ -60,9 +169,9 @@ body {
|
||||
|
||||
/* Button Styles */
|
||||
button.btn-neon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
background: var(--button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
color: var(--button-text);
|
||||
padding: 12px 24px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 30px;
|
||||
@@ -79,21 +188,21 @@ button.btn-neon {
|
||||
}
|
||||
|
||||
button.btn-neon:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: var(--button-hover-bg);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
box-shadow: var(--button-hover-shadow);
|
||||
}
|
||||
|
||||
button.btn-neon.active {
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 20px rgba(79, 172, 254, 0.4);
|
||||
box-shadow: var(--button-active-shadow);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.btn-neon.secondary {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-color: var(--button-secondary-border);
|
||||
background: var(--button-secondary-bg);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
@@ -101,14 +210,14 @@ button.btn-neon.secondary {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1);
|
||||
background: var(--scroll-track);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.2);
|
||||
background: var(--scroll-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-cyan);
|
||||
background: var(--scroll-thumb-hover);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
54
src/styles/scrollbar.css
Normal file
@@ -0,0 +1,54 @@
|
||||
|
||||
/* Fixed Scroll Bar */
|
||||
.fixed-scroll-bar {
|
||||
position: fixed;
|
||||
bottom: 15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 85%;
|
||||
max-width: 400px;
|
||||
height: 44px; /* Increased hit area */
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.fixed-scroll-track {
|
||||
width: 100%;
|
||||
height: 14px; /* Increased visual thickness */
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border-radius: 7px;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fixed-scroll-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-accent);
|
||||
border-radius: 7px;
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 12px rgba(0, 242, 255, 0.5);
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.fixed-scroll-thumb:active {
|
||||
cursor: grabbing;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 15px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
@@ -40,15 +40,36 @@ export function calculateHints(grid) {
|
||||
return { rowHints, colHints };
|
||||
}
|
||||
|
||||
export function generateRandomGrid(size) {
|
||||
export function generateRandomGrid(size, density = 0.5) {
|
||||
const grid = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = [];
|
||||
for (let j = 0; j < size; j++) {
|
||||
// ~50% chance of being filled
|
||||
row.push(Math.random() > 0.5 ? 1 : 0);
|
||||
row.push(Math.random() < density ? 1 : 0);
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function calculateDifficulty(density) {
|
||||
// Shannon Entropy: H(x) = -x*log2(x) - (1-x)*log2(1-x)
|
||||
// Normalized to 0-1 range (since max entropy at 0.5 is 1)
|
||||
|
||||
// Avoid log(0)
|
||||
if (density <= 0 || density >= 1) return 'easy';
|
||||
|
||||
const entropy = -density * Math.log2(density) - (1 - density) * Math.log2(1 - density);
|
||||
|
||||
// Thresholds based on entropy
|
||||
// 0.5 density -> entropy 1.0 (Extreme)
|
||||
// 0.4/0.6 density -> entropy ~0.97 (Extreme)
|
||||
// 0.3/0.7 density -> entropy ~0.88 (Hardest)
|
||||
// 0.2/0.8 density -> entropy ~0.72 (Harder)
|
||||
// <0.2/>0.8 density -> entropy <0.72 (Easy)
|
||||
|
||||
if (entropy >= 0.96) return 'extreme'; // approx 38% - 62%
|
||||
if (entropy >= 0.85) return 'hardest'; // approx 28% - 38% & 62% - 72%
|
||||
if (entropy >= 0.65) return 'harder'; // approx 17% - 28% & 72% - 83%
|
||||
return 'easy';
|
||||
}
|
||||
|
||||
51
src/utils/puzzleUtils.spec.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateHints } from './puzzleUtils'
|
||||
|
||||
describe('puzzleUtils', () => {
|
||||
it('calculateHints correctly calculates hints for a simple grid', () => {
|
||||
const grid = [
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 1, 0]
|
||||
]
|
||||
// Row 0: 1, then space, then 1 -> [1, 1]
|
||||
// Row 1: 1, 1, 1 -> [3]
|
||||
// Row 2: space, 1, space -> [1]
|
||||
|
||||
// Col 0: 1, 1, 0 -> [2]
|
||||
// Col 1: 0, 1, 1 -> [2] ? Wait. Col 1 is 0, 1, 1. So space, 1, 1 -> [2].
|
||||
// Let's trace col 1 manually:
|
||||
// r0,c1 = 0
|
||||
// r1,c1 = 1 -> count=1
|
||||
// r2,c1 = 1 -> count=2
|
||||
// end -> push 2.
|
||||
// So Col 1 is [2].
|
||||
|
||||
// Wait, my manual trace above for col 1:
|
||||
// grid[0][1] is 0.
|
||||
// grid[1][1] is 1.
|
||||
// grid[2][1] is 1.
|
||||
// Yes, [2].
|
||||
|
||||
// Col 2: 1, 1, 0 -> [2].
|
||||
|
||||
const expected = {
|
||||
rowHints: [[1, 1], [3], [1]],
|
||||
colHints: [[2], [2], [2]]
|
||||
}
|
||||
expect(calculateHints(grid)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('calculateHints handles empty rows/cols', () => {
|
||||
const grid = [
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0]
|
||||
]
|
||||
const expected = {
|
||||
rowHints: [[0], [0], [0]],
|
||||
colHints: [[0], [0], [0]]
|
||||
}
|
||||
expect(calculateHints(grid)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
249
src/workers/solverWorker.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { calculateHints } from '../utils/puzzleUtils.js';
|
||||
|
||||
const messages = {
|
||||
pl: {
|
||||
'worker.solved': 'Rozwiązane!',
|
||||
'worker.logicRow': 'Logika: Wiersz {row}, Kolumna {col} -> {state}',
|
||||
'worker.logicCol': 'Logika: Kolumna {col}, Wiersz {row} -> {state}',
|
||||
'worker.stuck': 'Brak logicznego ruchu. Spróbuj zgadnąć lub cofnąć.',
|
||||
'worker.done': 'Koniec!',
|
||||
'worker.state.filled': 'Pełne',
|
||||
'worker.state.empty': 'Puste'
|
||||
},
|
||||
en: {
|
||||
'worker.solved': 'Solved!',
|
||||
'worker.logicRow': 'Logic: Row {row}, Column {col} -> {state}',
|
||||
'worker.logicCol': 'Logic: Column {col}, Row {row} -> {state}',
|
||||
'worker.stuck': 'No logical move found. Try guessing or undoing.',
|
||||
'worker.done': 'Done!',
|
||||
'worker.state.filled': 'Filled',
|
||||
'worker.state.empty': 'Empty'
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLocale = (value) => {
|
||||
if (!value) return 'en';
|
||||
const short = String(value).toLowerCase().split('-')[0];
|
||||
return short === 'pl' ? 'pl' : 'en';
|
||||
};
|
||||
|
||||
const format = (text, params = {}) => {
|
||||
return text.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = params[key];
|
||||
return value === undefined ? `{${key}}` : String(value);
|
||||
});
|
||||
};
|
||||
|
||||
const t = (locale, key, params) => {
|
||||
const lang = messages[locale] || messages.en;
|
||||
const value = lang[key] || messages.en[key] || key;
|
||||
return typeof value === 'string' ? format(value, params) : key;
|
||||
};
|
||||
|
||||
const 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 };
|
||||
}
|
||||
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];
|
||||
const maxStart = n - suffixMin[hintIndex];
|
||||
for (let start = pos; start <= maxStart; start++) {
|
||||
if (hasFilled(pos, start)) 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, hintIndex + 1)) {
|
||||
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];
|
||||
const maxStart = pos - len;
|
||||
for (let start = maxStart; start >= 0; start--) {
|
||||
if (hasCross(start, start + len)) continue;
|
||||
if (start + len < pos && lineState[start + len] === 1) continue;
|
||||
if (hasFilled(start + len, pos)) continue;
|
||||
if (start > 0 && lineState[start - 1] === 1) continue;
|
||||
const prevPos = start > 0 ? start - 1 : 0;
|
||||
if (canPlacePrefix(prevPos, hintCount - 1)) {
|
||||
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 (!canPlacePrefix(start, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
const isSolved = (grid, solution) => {
|
||||
const size = grid.length;
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
const playerCell = grid[r][c];
|
||||
const solutionCell = solution[r][c];
|
||||
const isFilled = playerCell === 1;
|
||||
const shouldBeFilled = solutionCell === 1;
|
||||
if (isFilled !== shouldBeFilled) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStep = (playerGrid, solution, locale) => {
|
||||
if (isSolved(playerGrid, solution)) {
|
||||
return { type: 'done', statusText: t(locale, 'worker.solved') };
|
||||
}
|
||||
|
||||
const size = solution.length;
|
||||
const { rowHints, colHints } = calculateHints(solution);
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
const rowLine = playerGrid[r];
|
||||
const hints = rowHints[r];
|
||||
const result = solveLineLogic(rowLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateLabel = t(locale, result.state === 1 ? 'worker.state.filled' : 'worker.state.empty');
|
||||
return {
|
||||
type: 'move',
|
||||
r,
|
||||
c: result.index,
|
||||
state: result.state,
|
||||
statusText: t(locale, 'worker.logicRow', { row: r + 1, col: result.index + 1, state: stateLabel })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let c = 0; c < size; c++) {
|
||||
const colLine = [];
|
||||
for (let r = 0; r < size; r++) colLine.push(playerGrid[r][c]);
|
||||
const hints = colHints[c];
|
||||
const result = solveLineLogic(colLine, hints);
|
||||
if (result.index !== -1) {
|
||||
const stateLabel = t(locale, result.state === 1 ? 'worker.state.filled' : 'worker.state.empty');
|
||||
return {
|
||||
type: 'move',
|
||||
r: result.index,
|
||||
c,
|
||||
state: result.state,
|
||||
statusText: t(locale, 'worker.logicCol', { row: result.index + 1, col: c + 1, state: stateLabel })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for guess logic - we want to avoid this unless strictly necessary
|
||||
// If no logic move found, return 'stuck' instead of cheating
|
||||
return { type: 'stuck', statusText: t(locale, 'worker.stuck') };
|
||||
};
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const { id, playerGrid, solution, locale } = event.data;
|
||||
const resolved = resolveLocale(locale);
|
||||
const result = handleStep(playerGrid, solution, resolved);
|
||||
self.postMessage({ id, ...result });
|
||||
};
|
||||
60
update_i18n_guide.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const filePath = 'src/composables/useI18n.js';
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 1. Add key to all language objects
|
||||
const lines = content.split('\n');
|
||||
const newLines = [];
|
||||
let insideLang = false;
|
||||
let currentLang = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const langStartMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
|
||||
if (langStartMatch) {
|
||||
insideLang = true;
|
||||
currentLang = langStartMatch[1].replace(/['"]/g, '');
|
||||
}
|
||||
|
||||
if (insideLang && (line.trim() === '},' || line.trim() === '}')) {
|
||||
let translation = 'GUIDE';
|
||||
if (currentLang === 'pl') translation = 'PRZEWODNIK';
|
||||
if (currentLang === 'es') translation = 'GUÍA';
|
||||
if (currentLang === 'fr') translation = 'GUIDE';
|
||||
if (currentLang === 'de') translation = 'ANLEITUNG';
|
||||
if (currentLang === 'it') translation = 'GUIDA';
|
||||
if (currentLang === 'pt' || currentLang === 'pt-br') translation = 'GUIA';
|
||||
if (currentLang === 'ru') translation = 'РУКОВОДСТВО';
|
||||
if (currentLang === 'zh') translation = '指南';
|
||||
|
||||
// Ensure previous line has comma
|
||||
if (newLines.length > 0) {
|
||||
const lastLine = newLines[newLines.length - 1];
|
||||
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
|
||||
newLines[newLines.length - 1] = lastLine + ',';
|
||||
}
|
||||
}
|
||||
|
||||
newLines.push(` 'nav.guide': '${translation}'`);
|
||||
insideLang = false;
|
||||
currentLang = null;
|
||||
}
|
||||
|
||||
newLines.push(line);
|
||||
}
|
||||
|
||||
content = newLines.join('\n');
|
||||
|
||||
// 2. Add to requiredKeys
|
||||
// Find "const requiredKeys = ["
|
||||
// We know it ends with 'nav.newGame' now.
|
||||
content = content.replace(
|
||||
"'nav.newGame'",
|
||||
"'nav.newGame','nav.guide'"
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log('Updated useI18n.js with nav.guide');
|
||||
@@ -1,12 +1,51 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
'__APP_VERSION__': JSON.stringify(process.env.npm_package_version)
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
injectRegister: 'auto',
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}']
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true
|
||||
},
|
||||
manifest: {
|
||||
name: 'Nonograms',
|
||||
short_name: 'Nonograms',
|
||||
description: 'Nonograms',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#191654',
|
||||
theme_color: '#00f2fe',
|
||||
icons: [
|
||||
{
|
||||
src: '/pwa-192x192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml'
|
||||
},
|
||||
{
|
||||
src: '/pwa-512x512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
16
vitest.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
})
|
||||