Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
43
SOLID_EXPLANATION.md
Normal file
43
SOLID_EXPLANATION.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dokumentacja Architektury SOLID + Vue 3
|
||||
|
||||
Ten projekt został przepisany zgodnie z zasadami SOLID i najlepszymi praktykami Vue 3.
|
||||
|
||||
## Implementacja Zasad SOLID
|
||||
|
||||
### S - Single Responsibility Principle (Zasada Jednej Odpowiedzialności)
|
||||
Każdy komponent i plik ma jedną, ściśle określoną rolę:
|
||||
- **`components/Cell.vue`**: Odpowiada TYLKO za wyświetlanie pojedynczej komórki i emitowanie zdarzeń kliknięcia. Nie wie nic o logice gry.
|
||||
- **`components/Hints.vue`**: Odpowiada TYLKO za wyświetlanie liczb (podpowiedzi).
|
||||
- **`composables/useNonogram.js`**: Zawiera logikę interakcji (kliknięcia, efekty).
|
||||
- **`stores/puzzle.js`**: Zarządza stanem aplikacji (grid, level, timer).
|
||||
|
||||
### O - Open/Closed Principle (Zasada Otwarte/Zamknięte)
|
||||
System jest otwarty na rozszerzenia, ale zamknięty na modyfikacje:
|
||||
- **Nowe poziomy**: Można dodać nowe zagadki w `stores/puzzle.js` (obiekt `PUZZLES`) bez zmieniania logiki renderowania siatki czy sprawdzania wygranej.
|
||||
- **Theme/Styl**: Style są oparte na zmiennych CSS (`main.css`), co pozwala na łatwą zmianę motywu bez ingerencji w komponenty.
|
||||
|
||||
### L - Liskov Substitution Principle (Zasada Podstawienia Liskov)
|
||||
W kontekście Vue, komponenty są wymienne i przewidywalne:
|
||||
- Komponenty `Cell` i `Hints` przyjmują proste propsy (`state`, `hints`) i nie polegają na "magicznych" globalnych stanach wewnątrz swojej struktury renderowania (poza wstrzykiwanym storem w kontenerach wyższego rzędu).
|
||||
|
||||
### I - Interface Segregation Principle (Zasada Segregacji Interfejsów)
|
||||
Komponenty otrzymują tylko te dane, których potrzebują:
|
||||
- `Cell.vue` otrzymuje tylko `state`, `r`, `c`. Nie dostaje całego obiektu `puzzle` ani `store`.
|
||||
- `Hints.vue` otrzymuje tylko tablicę liczb, a nie całą logikę gry.
|
||||
|
||||
### D - Dependency Inversion Principle (Zasada Odwrócenia Zależności)
|
||||
Wysokopoziomowe moduły nie zależą od niskopoziomowych szczegółów:
|
||||
- **Pinia Store**: Logika gry jest wstrzykiwana przez `usePuzzleStore`. Komponenty UI (`Controls`, `GameBoard`) zależą od abstrakcji store'a, a nie od konkretnej implementacji logiki wewnątrz komponentu.
|
||||
- **Composables**: Logika (`useHints`, `useTimer`) jest wydzielona do reużywalnych funkcji, co uniezależnia ją od cyklu życia konkretnego komponentu Vue.
|
||||
|
||||
## Struktura Projektu
|
||||
|
||||
- `src/components/`: Komponenty "głupie" (prezentacyjne) oraz kontenery.
|
||||
- `src/composables/`: Logika biznesowa (Hooki).
|
||||
- `src/stores/`: Globalny stan aplikacji (Pinia).
|
||||
- `src/styles/`: Globalne style i zmienne.
|
||||
|
||||
## Uruchomienie
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run dev`
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nonograms Pro - Vue 3 SOLID</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1273
package-lock.json
generated
Normal file
1273
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "vue-nonograms-solid",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.19",
|
||||
"canvas-confetti": "^1.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
114
src/App.vue
Normal file
114
src/App.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { usePuzzleStore } from './stores/puzzle';
|
||||
import GameBoard from './components/GameBoard.vue';
|
||||
import LevelSelector from './components/LevelSelector.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';
|
||||
|
||||
// Main App Entry
|
||||
const store = usePuzzleStore();
|
||||
const showCustomModal = ref(false);
|
||||
const showGuide = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.loadState()) {
|
||||
store.initGame(); // Inicjalizacja domyślnej gry jeśli brak zapisu
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="game-container">
|
||||
<FixedBar />
|
||||
|
||||
<header class="game-header">
|
||||
<h1>NONOGRAMY</h1>
|
||||
<div class="underline"></div>
|
||||
</header>
|
||||
|
||||
<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" />
|
||||
</transition>
|
||||
|
||||
<!-- Status Panel (Time, Moves, Progress) -->
|
||||
<StatusPanel />
|
||||
|
||||
<!-- Game Actions (Reset, Random, Undo, Check) -->
|
||||
<GameActions />
|
||||
|
||||
<!-- Game Board -->
|
||||
<section class="board-section">
|
||||
<GameBoard />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modals Teleport -->
|
||||
<Teleport to="body">
|
||||
<WinModal v-if="store.isGameWon" />
|
||||
<CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" />
|
||||
</Teleport>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.underline {
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background: var(--primary-accent);
|
||||
margin: 10px auto 0;
|
||||
box-shadow: 0 0 10px var(--primary-accent);
|
||||
}
|
||||
|
||||
.game-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Remove old glass panel style from game-layout since we split it */
|
||||
.board-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
79
src/components/Cell.vue
Normal file
79
src/components/Cell.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (v) => [0, 1, 2].includes(v)
|
||||
},
|
||||
r: Number,
|
||||
c: Number
|
||||
});
|
||||
|
||||
const emit = defineEmits(['start-drag', 'enter-cell']);
|
||||
|
||||
const cellClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 1: return 'filled';
|
||||
case 2: return 'cross';
|
||||
default: return 'empty';
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
// 0 = left, 2 = right
|
||||
if (e.button === 0) emit('start-drag', props.r, props.c, false);
|
||||
if (e.button === 2) emit('start-drag', props.r, props.c, true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell"
|
||||
:class="cellClass"
|
||||
@mousedown.prevent="handleMouseDown"
|
||||
@mouseenter="emit('enter-cell', props.r, props.c)"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span v-if="props.state === 2" class="cross-mark">×</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cell {
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
background-color: var(--cell-empty);
|
||||
border: 1px solid var(--glass-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s ease, box-shadow 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
background-color: var(--cell-hover);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.cell.filled {
|
||||
background: var(--cell-filled-gradient);
|
||||
box-shadow: 0 0 15px var(--accent-cyan);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cell.cross {
|
||||
color: var(--cell-x-color);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Guide Lines Logic (handled via CSS classes passed from parent usually, but here simpler to use nth-child or props) */
|
||||
/* Actually, user wants guide lines every 5 cells.
|
||||
We can do this in GameBoard via classes on cells or border manipulation.
|
||||
Let's do it in GameBoard style or pass a prop 'isGuideRight', 'isGuideBottom'.
|
||||
*/
|
||||
</style>
|
||||
129
src/components/CustomGameModal.vue
Normal file
129
src/components/CustomGameModal.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const store = usePuzzleStore();
|
||||
|
||||
const customSize = ref(10);
|
||||
const errorMsg = ref('');
|
||||
|
||||
const confirm = () => {
|
||||
const size = parseInt(customSize.value);
|
||||
if (isNaN(size) || size < 5 || size > 100) {
|
||||
errorMsg.value = 'Rozmiar musi być między 5 a 100!';
|
||||
return;
|
||||
}
|
||||
|
||||
store.initCustomGame(size);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="number"
|
||||
v-model="customSize"
|
||||
min="5"
|
||||
max="100"
|
||||
@keyup.enter="confirm"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
box-shadow: 0 0 50px rgba(0, 242, 255, 0.2);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0 0 20px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4d4d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(50px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
136
src/components/FixedBar.vue
Normal file
136
src/components/FixedBar.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { formatTime } = useTimer();
|
||||
|
||||
const isVisible = ref(false);
|
||||
const isProgressVisible = ref(true); // Toggle for progress percentage
|
||||
|
||||
// Logic to show bar on scroll
|
||||
window.addEventListener('scroll', () => {
|
||||
isVisible.value = window.scrollY > 100;
|
||||
});
|
||||
|
||||
const progressText = computed(() => {
|
||||
const percent = store.progressPercentage;
|
||||
if (typeof percent !== 'number') return '0.0%';
|
||||
return isProgressVisible.value
|
||||
? `${percent.toFixed(1)}%`
|
||||
: '???';
|
||||
});
|
||||
|
||||
const formattedTime = computed(() => formatTime(store.elapsedTime));
|
||||
|
||||
const toggleVisibility = () => {
|
||||
isProgressVisible.value = !isProgressVisible.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="fixed-bar" :class="{ visible: isVisible }">
|
||||
<div class="fixed-content">
|
||||
<div class="fixed-stat">
|
||||
<span>Czas:</span>
|
||||
<span>{{ formattedTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="fixed-stat">
|
||||
<span>Postęp:</span>
|
||||
<span style="min-width: 60px; text-align: right;">{{ progressText }}</span>
|
||||
<button class="btn-eye" @click="toggleVisibility" :title="isProgressVisible ? 'Ukryj' : 'Pokaż'">
|
||||
<span v-if="isProgressVisible">👁️</span>
|
||||
<span v-else>🔒</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-line-container">
|
||||
<div class="progress-line-fill" :style="{ width: `${store.progressPercentage || 0}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#fixed-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
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);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#fixed-bar.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.fixed-content {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 300;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fixed-stat {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fixed-stat span:first-child {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.progress-line-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.progress-line-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-eye {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 5px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-eye:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
47
src/components/GameActions.vue
Normal file
47
src/components/GameActions.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
107
src/components/GameBoard.vue
Normal file
107
src/components/GameBoard.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useHints } from '@/composables/useHints';
|
||||
import { useNonogram } from '@/composables/useNonogram';
|
||||
import Cell from './Cell.vue';
|
||||
import Hints from './Hints.vue';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { rowHints, colHints } = useHints(computed(() => store.solution));
|
||||
const { startDrag, onMouseEnter, stopDrag } = useNonogram();
|
||||
|
||||
// Global mouseup to stop dragging even if mouse leaves grid
|
||||
const handleGlobalMouseUp = () => {
|
||||
stopDrag();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-board-wrapper">
|
||||
<div class="game-container">
|
||||
<div class="corner-spacer"></div>
|
||||
|
||||
<!-- Column Hints -->
|
||||
<Hints :hints="colHints" orientation="col" />
|
||||
|
||||
<!-- Row Hints -->
|
||||
<Hints :hints="rowHints" orientation="row" />
|
||||
|
||||
<!-- Grid -->
|
||||
<div
|
||||
class="grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${store.size}, var(--cell-size))`,
|
||||
gridTemplateRows: `repeat(${store.size}, var(--cell-size))`
|
||||
}"
|
||||
@mouseleave="stopDrag"
|
||||
>
|
||||
<template v-for="(row, r) in store.playerGrid" :key="r">
|
||||
<Cell
|
||||
v-for="(state, c) in row"
|
||||
:key="`${r}-${c}`"
|
||||
:state="state"
|
||||
:r="r"
|
||||
:c="c"
|
||||
:class="{
|
||||
'guide-right': (c + 1) % 5 === 0 && c !== store.size - 1,
|
||||
'guide-bottom': (r + 1) % 5 === 0 && r !== store.size - 1
|
||||
}"
|
||||
@start-drag="startDrag"
|
||||
@enter-cell="onMouseEnter"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-board-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
display: grid;
|
||||
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);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.corner-spacer {
|
||||
width: 100px; /* Must match Row Hints width */
|
||||
height: auto; /* Adapts to Col Hints height */
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--gap-size);
|
||||
padding: 5px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Guide Lines */
|
||||
:deep(.cell.guide-right) {
|
||||
border-right: 2px solid rgba(0, 242, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
:deep(.cell.guide-bottom) {
|
||||
border-bottom: 2px solid rgba(0, 242, 255, 0.5) !important;
|
||||
}
|
||||
</style>
|
||||
73
src/components/GuidePanel.vue
Normal file
73
src/components/GuidePanel.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { useSolver } from '@/composables/useSolver';
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
speedLabel,
|
||||
statusText,
|
||||
step,
|
||||
togglePlay,
|
||||
changeSpeed
|
||||
} = useSolver();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-panel">
|
||||
<div class="status-text">{{ statusText }}</div>
|
||||
|
||||
<div class="guide-controls">
|
||||
<button class="btn-neon small" @click="togglePlay" :class="{ active: isPlaying }">
|
||||
{{ isPlaying ? 'PAUSE' : 'PLAY' }}
|
||||
</button>
|
||||
|
||||
<button class="btn-neon small" @click="step" :disabled="isPlaying">
|
||||
STEP
|
||||
</button>
|
||||
|
||||
<button class="btn-neon small" @click="changeSpeed">
|
||||
SPEED: {{ speedLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-panel {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(0, 242, 254, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
min-height: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.guide-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-neon.small {
|
||||
padding: 5px 15px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
.btn-neon.small {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
95
src/components/Hints.vue
Normal file
95
src/components/Hints.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
hints: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
orientation: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (v) => ['row', 'col'].includes(v)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hints-container" :class="orientation">
|
||||
<div
|
||||
v-for="(group, index) in hints"
|
||||
:key="index"
|
||||
class="hint-group"
|
||||
:class="{ 'hint-alt': index % 2 !== 0 }"
|
||||
>
|
||||
<span
|
||||
v-for="(num, idx) in group"
|
||||
:key="idx"
|
||||
class="hint-num"
|
||||
>
|
||||
{{ num }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hints-container {
|
||||
display: flex;
|
||||
gap: var(--gap-size);
|
||||
}
|
||||
|
||||
.hints-container.col {
|
||||
flex-direction: row;
|
||||
margin-bottom: 5px;
|
||||
align-items: flex-end;
|
||||
padding: 0 5px; /* Match grid padding */
|
||||
}
|
||||
|
||||
.hints-container.row {
|
||||
flex-direction: column;
|
||||
margin-right: 5px;
|
||||
align-items: flex-end;
|
||||
padding: 5px 0; /* Match grid padding */
|
||||
}
|
||||
|
||||
.hint-group {
|
||||
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);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.col .hint-group {
|
||||
flex-direction: column;
|
||||
width: var(--cell-size);
|
||||
padding: 4px 2px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.row .hint-group {
|
||||
flex-direction: row;
|
||||
height: var(--cell-size);
|
||||
padding: 2px 8px;
|
||||
width: 100px; /* Stała szerokość */
|
||||
}
|
||||
|
||||
.hint-num {
|
||||
font-size: 0.85rem;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Alternating Colors */
|
||||
.hint-group.hint-alt .hint-num {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Hover effect for readability */
|
||||
.hint-group:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
</style>
|
||||
64
src/components/LevelSelector.vue
Normal file
64
src/components/LevelSelector.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<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>
|
||||
86
src/components/StatusPanel.vue
Normal file
86
src/components/StatusPanel.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { useTimer } from '@/composables/useTimer';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
const { formatTime } = useTimer();
|
||||
|
||||
const formattedTime = computed(() => formatTime(store.elapsedTime));
|
||||
const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-panel {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 20px 40px;
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.8rem;
|
||||
color: #fff;
|
||||
font-weight: 300;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.value.small {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
91
src/components/WinModal.vue
Normal file
91
src/components/WinModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
|
||||
const store = usePuzzleStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal glass-panel">
|
||||
<h2>GRATULACJE!</h2>
|
||||
<p>Rozwiązałeś zagadkę!</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span>Czas:</span>
|
||||
<strong>{{ store.elapsedTime }}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-neon" @click="store.resetGame">Zagraj Ponownie</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
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);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-accent);
|
||||
margin: 0 0 10px 0;
|
||||
text-shadow: 0 0 20px var(--primary-accent);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
color: #fff;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(50px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
14
src/composables/useHints.js
Normal file
14
src/composables/useHints.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { computed } from 'vue';
|
||||
import { calculateHints } from '@/utils/puzzleUtils';
|
||||
|
||||
export function useHints(solutionGrid) {
|
||||
const hints = computed(() => calculateHints(solutionGrid.value));
|
||||
|
||||
const rowHints = computed(() => hints.value.rowHints);
|
||||
const colHints = computed(() => hints.value.colHints);
|
||||
|
||||
return {
|
||||
rowHints,
|
||||
colHints
|
||||
};
|
||||
}
|
||||
126
src/composables/useNonogram.js
Normal file
126
src/composables/useNonogram.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ref } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export function useNonogram() {
|
||||
const store = usePuzzleStore();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragMode = ref(null); // 'fill', 'empty', 'cross'
|
||||
const startCellState = ref(null);
|
||||
|
||||
const startDrag = (r, c, isRightClick = false) => {
|
||||
if (store.isGameWon) return;
|
||||
|
||||
isDragging.value = true;
|
||||
const current = store.playerGrid[r][c];
|
||||
|
||||
if (isRightClick) {
|
||||
// Right click logic
|
||||
// If current is 1 (filled), do nothing usually? Or ignore?
|
||||
// Standard: Toggle 0 <-> 2
|
||||
if (current === 1) {
|
||||
dragMode.value = null; // invalid drag start
|
||||
return;
|
||||
}
|
||||
dragMode.value = (current === 2) ? 0 : 2;
|
||||
} else {
|
||||
// Left click logic
|
||||
// Toggle 0 <-> 1. Ignore 2 usually or overwrite?
|
||||
// Standard: If 2, usually safe to overwrite or ignore. Let's say we toggle 0->1, 1->0.
|
||||
// If starting on 2, maybe clear it?
|
||||
if (current === 2) {
|
||||
dragMode.value = 0; // Clear cross
|
||||
} else {
|
||||
dragMode.value = (current === 1) ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to start cell
|
||||
applyDrag(r, c);
|
||||
};
|
||||
|
||||
const onMouseEnter = (r, c) => {
|
||||
if (isDragging.value) {
|
||||
applyDrag(r, c);
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
dragMode.value = null;
|
||||
checkWinEffect();
|
||||
};
|
||||
|
||||
const applyDrag = (r, c) => {
|
||||
if (dragMode.value === null) return;
|
||||
|
||||
const current = store.playerGrid[r][c];
|
||||
|
||||
// Validation:
|
||||
// Don't overwrite filled (1) with cross (2) directly usually?
|
||||
// Or don't overwrite cross (2) with filled (1)?
|
||||
// Simple logic:
|
||||
// If dragMode is 1 (filling): only fill 0 or 2.
|
||||
// If dragMode is 0 (clearing): clear 1 or 2.
|
||||
// If dragMode is 2 (crossing): only cross 0.
|
||||
|
||||
let shouldApply = false;
|
||||
if (dragMode.value === 1) {
|
||||
if (current !== 1) shouldApply = true;
|
||||
} else if (dragMode.value === 2) {
|
||||
if (current === 0) shouldApply = true; // Only cross empty
|
||||
if (current === 2 && dragMode.value === 0) shouldApply = true; // Clear cross
|
||||
} else if (dragMode.value === 0) {
|
||||
// Clearing
|
||||
if (current !== 0) shouldApply = true;
|
||||
}
|
||||
|
||||
// Simplification for UX: Just force set if valid transition
|
||||
// But avoid overwriting 1 with 2 if unintended.
|
||||
|
||||
// Let's stick to: "Paint with dragMode"
|
||||
// But protect existing "Opposite" marks if desired.
|
||||
// For now, simple paint is fine.
|
||||
|
||||
store.setCell(r, c, dragMode.value);
|
||||
};
|
||||
|
||||
const checkWinEffect = () => {
|
||||
if (store.isGameWon) {
|
||||
triggerConfetti();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerConfetti = () => {
|
||||
const duration = 3000;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
(function frame() {
|
||||
confetti({
|
||||
particleCount: 5,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0 },
|
||||
colors: ['#00f2ff', '#ff0055', '#ffffff']
|
||||
});
|
||||
confetti({
|
||||
particleCount: 5,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1 },
|
||||
colors: ['#00f2ff', '#ff0055', '#ffffff']
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}());
|
||||
};
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
onMouseEnter,
|
||||
stopDrag
|
||||
};
|
||||
}
|
||||
211
src/composables/useSolver.js
Normal file
211
src/composables/useSolver.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { usePuzzleStore } from '@/stores/puzzle';
|
||||
import { calculateHints } from '@/utils/puzzleUtils';
|
||||
|
||||
export function useSolver() {
|
||||
const store = usePuzzleStore();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const speedIndex = ref(0);
|
||||
const speeds = [1000, 500, 250, 125];
|
||||
const speedLabels = ['x1', 'x2', 'x3', 'x4'];
|
||||
const statusText = ref('Oczekiwanie...');
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function step() {
|
||||
if (store.isGameWon) {
|
||||
pause();
|
||||
statusText.value = "Rozwiązane!";
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying.value) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
function play() {
|
||||
isPlaying.value = true;
|
||||
step(); // Immediate step
|
||||
intervalId = setInterval(step, speeds[speedIndex.value]);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying.value = false;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
|
||||
function changeSpeed() {
|
||||
speedIndex.value = (speedIndex.value + 1) % speeds.length;
|
||||
if (isPlaying.value) {
|
||||
pause();
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
speedIndex,
|
||||
speedLabel: computed(() => speedLabels[speedIndex.value]),
|
||||
statusText,
|
||||
step,
|
||||
togglePlay,
|
||||
changeSpeed
|
||||
};
|
||||
}
|
||||
49
src/composables/useTimer.js
Normal file
49
src/composables/useTimer.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
export function useTimer() {
|
||||
const time = ref(0);
|
||||
const timerInterval = ref(null);
|
||||
const isRunning = ref(false);
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (isRunning.value) return;
|
||||
isRunning.value = true;
|
||||
const startTime = Date.now() - (time.value * 1000);
|
||||
|
||||
timerInterval.value = setInterval(() => {
|
||||
time.value = Math.floor((Date.now() - startTime) / 1000);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value);
|
||||
timerInterval.value = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
stop();
|
||||
time.value = 0;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
return {
|
||||
time,
|
||||
isRunning,
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
formatTime
|
||||
};
|
||||
}
|
||||
31
src/main.js
Normal file
31
src/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
// Custom directive v-cell-hover (zgodnie z wymaganiami)
|
||||
// Służy do podświetlania wiersza i kolumny po najechaniu na komórkę
|
||||
const vCellHover = {
|
||||
mounted(el, binding) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
// Implementacja logiki hover w komponencie jest zwykle lepsza dla reaktywności Vue,
|
||||
// ale jako dyrektywa może manipulować klasami DOM dla wydajności.
|
||||
// Tutaj przekażemy zdarzenie do store lub komponentu wyżej, ale
|
||||
// dla uproszczenia w dyrektywie, po prostu emitujemy custom event
|
||||
el.dispatchEvent(new CustomEvent('cell-hover', {
|
||||
bubbles: true,
|
||||
detail: binding.value
|
||||
}));
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.dispatchEvent(new CustomEvent('cell-leave', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.directive('cell-hover', vCellHover)
|
||||
|
||||
app.mount('#app')
|
||||
346
src/stores/puzzle.js
Normal file
346
src/stores/puzzle.js
Normal file
@@ -0,0 +1,346 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { generateRandomGrid } from '@/utils/puzzleUtils';
|
||||
|
||||
// Definicje zagadek (Static Puzzles)
|
||||
const PUZZLES = {
|
||||
easy: {
|
||||
id: 'easy',
|
||||
name: 'Uśmiech',
|
||||
size: 5,
|
||||
grid: [
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 0, 0, 0, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
medium: {
|
||||
id: 'medium',
|
||||
name: 'Domek',
|
||||
size: 10,
|
||||
grid: [
|
||||
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
[1, 0, 0, 1, 1, 1, 1, 0, 0, 1],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0]
|
||||
]
|
||||
},
|
||||
hard: {
|
||||
id: 'hard',
|
||||
name: 'Statek',
|
||||
size: 15,
|
||||
grid: [
|
||||
[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0],
|
||||
[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0],
|
||||
[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0],
|
||||
[0,0,0,0,0,1,1,1,1,1,0,0,0,0,0]
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const usePuzzleStore = defineStore('puzzle', () => {
|
||||
// State
|
||||
const currentLevelId = ref('easy');
|
||||
const solution = ref([]);
|
||||
const playerGrid = ref([]); // 0: empty, 1: filled, 2: cross
|
||||
const isGameWon = ref(false);
|
||||
const size = ref(5);
|
||||
const startTime = ref(null);
|
||||
const elapsedTime = ref(0);
|
||||
const moves = ref(0);
|
||||
const timerInterval = ref(null);
|
||||
|
||||
// History for undo
|
||||
const history = ref([]);
|
||||
|
||||
// Progress State
|
||||
const totalCellsToFill = computed(() => {
|
||||
return solution.value.flat().filter(c => c === 1).length;
|
||||
});
|
||||
|
||||
const filledCorrectly = computed(() => {
|
||||
let count = 0;
|
||||
if (solution.value.length === 0 || playerGrid.value.length === 0) return 0;
|
||||
|
||||
for (let r = 0; r < size.value; r++) {
|
||||
for (let c = 0; c < size.value; c++) {
|
||||
// Zliczamy tylko poprawne wypełnienia (czarne),
|
||||
// ale w nonogramach postęp to często: (poprawne_czarne - bledne_czarne) / total_czarne
|
||||
// Zróbmy prostą wersję: % poprawnie zaznaczonych czarnych - błędnie zaznaczone czarne
|
||||
if (playerGrid.value[r][c] === 1) {
|
||||
if (solution.value[r][c] === 1) count++;
|
||||
else count--; // kara za błąd
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.max(0, count);
|
||||
});
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (totalCellsToFill.value === 0) return 0;
|
||||
return Math.min(100, (filledCorrectly.value / totalCellsToFill.value) * 100);
|
||||
});
|
||||
|
||||
// Actions
|
||||
function initGame(levelId = 'easy') {
|
||||
stopTimer();
|
||||
currentLevelId.value = levelId;
|
||||
|
||||
let puzzle = PUZZLES[levelId];
|
||||
if (!puzzle) {
|
||||
// Fallback or custom logic if needed, but for predefined levels:
|
||||
puzzle = PUZZLES['easy'];
|
||||
}
|
||||
|
||||
size.value = puzzle.size;
|
||||
solution.value = puzzle.grid;
|
||||
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
}
|
||||
|
||||
function initCustomGame(customSize) {
|
||||
stopTimer();
|
||||
currentLevelId.value = 'custom';
|
||||
size.value = customSize;
|
||||
|
||||
// Generate random grid
|
||||
solution.value = generateRandomGrid(customSize);
|
||||
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
}
|
||||
|
||||
function resetGrid() {
|
||||
playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0));
|
||||
moves.value = 0;
|
||||
history.value = [];
|
||||
}
|
||||
|
||||
function pushHistory() {
|
||||
const gridCopy = playerGrid.value.map(row => [...row]);
|
||||
history.value.push(gridCopy);
|
||||
if (history.value.length > 50) history.value.shift();
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (history.value.length === 0 || isGameWon.value) return;
|
||||
const previousState = history.value.pop();
|
||||
playerGrid.value = previousState;
|
||||
moves.value++;
|
||||
saveState();
|
||||
}
|
||||
|
||||
function toggleCell(r, c, isRightClick = false) {
|
||||
if (isGameWon.value) return;
|
||||
|
||||
pushHistory();
|
||||
|
||||
const currentState = playerGrid.value[r][c];
|
||||
let newState;
|
||||
|
||||
if (isRightClick) {
|
||||
if (currentState === 1) return; // Don't override filled
|
||||
newState = currentState === 2 ? 0 : 2;
|
||||
} else {
|
||||
if (currentState === 2) return; // Don't override cross
|
||||
newState = currentState === 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
playerGrid.value[r][c] = newState; // This triggers reactivity
|
||||
moves.value++;
|
||||
checkWin();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function setCell(r, c, state) {
|
||||
if (isGameWon.value) return;
|
||||
if (playerGrid.value[r][c] !== state) {
|
||||
pushHistory();
|
||||
playerGrid.value[r][c] = state;
|
||||
moves.value++;
|
||||
checkWin();
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
|
||||
function checkWin() {
|
||||
let correct = true;
|
||||
for (let r = 0; r < size.value; r++) {
|
||||
for (let c = 0; c < size.value; c++) {
|
||||
const playerCell = playerGrid.value[r][c];
|
||||
const solutionCell = solution.value[r][c];
|
||||
|
||||
const isFilled = playerCell === 1;
|
||||
const shouldBeFilled = solutionCell === 1;
|
||||
|
||||
if (isFilled !== shouldBeFilled) {
|
||||
correct = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!correct) break;
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
isGameWon.value = true;
|
||||
stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerInterval.value) clearInterval(timerInterval.value);
|
||||
startTime.value = Date.now() - (elapsedTime.value * 1000); // Adjust start time based on elapsed
|
||||
timerInterval.value = setInterval(() => {
|
||||
elapsedTime.value = Math.floor((Date.now() - startTime.value) / 1000);
|
||||
saveState();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value);
|
||||
timerInterval.value = null;
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
// Persistence
|
||||
const STORAGE_KEY = 'nonogram_state_v1';
|
||||
|
||||
function saveState() {
|
||||
const stateToSave = {
|
||||
currentLevelId: currentLevelId.value,
|
||||
size: size.value,
|
||||
solution: solution.value,
|
||||
playerGrid: playerGrid.value,
|
||||
isGameWon: isGameWon.value,
|
||||
elapsedTime: elapsedTime.value,
|
||||
moves: moves.value,
|
||||
history: history.value
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
currentLevelId.value = parsed.currentLevelId;
|
||||
size.value = parsed.size;
|
||||
solution.value = parsed.solution;
|
||||
playerGrid.value = parsed.playerGrid;
|
||||
isGameWon.value = parsed.isGameWon;
|
||||
elapsedTime.value = parsed.elapsedTime || 0;
|
||||
moves.value = parsed.moves || 0;
|
||||
history.value = parsed.history || [];
|
||||
|
||||
if (!isGameWon.value) {
|
||||
startTimer();
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to load save', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 toggleCell/setCell removed
|
||||
|
||||
function resetGame() {
|
||||
if (currentLevelId.value === 'custom') {
|
||||
resetGrid();
|
||||
isGameWon.value = false;
|
||||
elapsedTime.value = 0;
|
||||
startTimer();
|
||||
saveState();
|
||||
} else {
|
||||
initGame(currentLevelId.value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentLevelId,
|
||||
solution,
|
||||
playerGrid,
|
||||
isGameWon,
|
||||
size,
|
||||
elapsedTime,
|
||||
progressPercentage,
|
||||
initGame,
|
||||
initCustomGame,
|
||||
toggleCell,
|
||||
setCell,
|
||||
resetGame,
|
||||
checkWin,
|
||||
loadState, // expose loadState
|
||||
moves,
|
||||
undo
|
||||
};
|
||||
|
||||
});
|
||||
121
src/styles/main.css
Normal file
121
src/styles/main.css
Normal file
@@ -0,0 +1,121 @@
|
||||
:root {
|
||||
/* --- Glassmorphism Design System --- */
|
||||
--bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%);
|
||||
--glass-bg: rgba(255, 255, 255, 0.1);
|
||||
--glass-border: rgba(255, 255, 255, 0.2);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
--text-color: #ffffff;
|
||||
--accent-cyan: #00f2fe;
|
||||
--accent-purple: #4facfe;
|
||||
--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);
|
||||
|
||||
/* Rozmiary */
|
||||
--cell-size: 30px;
|
||||
--gap-size: 2px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure no other content is visible */
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Glass Panel Utility */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
button.btn-neon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button.btn-neon:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
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);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.btn-neon.secondary {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
54
src/utils/puzzleUtils.js
Normal file
54
src/utils/puzzleUtils.js
Normal file
@@ -0,0 +1,54 @@
|
||||
export function calculateHints(grid) {
|
||||
if (!grid || grid.length === 0) return { rowHints: [], colHints: [] };
|
||||
|
||||
const size = grid.length;
|
||||
const rowHints = [];
|
||||
const colHints = [];
|
||||
|
||||
// Row Hints
|
||||
for (let r = 0; r < size; r++) {
|
||||
const hints = [];
|
||||
let count = 0;
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (grid[r][c] === 1) {
|
||||
count++;
|
||||
} else if (count > 0) {
|
||||
hints.push(count);
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
if (count > 0) hints.push(count);
|
||||
rowHints.push(hints.length > 0 ? hints : [0]);
|
||||
}
|
||||
|
||||
// Col Hints
|
||||
for (let c = 0; c < size; c++) {
|
||||
const hints = [];
|
||||
let count = 0;
|
||||
for (let r = 0; r < size; r++) {
|
||||
if (grid[r][c] === 1) {
|
||||
count++;
|
||||
} else if (count > 0) {
|
||||
hints.push(count);
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
if (count > 0) hints.push(count);
|
||||
colHints.push(hints.length > 0 ? hints : [0]);
|
||||
}
|
||||
|
||||
return { rowHints, colHints };
|
||||
}
|
||||
|
||||
export function generateRandomGrid(size) {
|
||||
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);
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
12
vite.config.js
Normal file
12
vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user