Initial commit

This commit is contained in:
2026-02-08 01:06:19 +01:00
commit 235fd3022f
25 changed files with 3339 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
.vscode
.idea
*.log

43
SOLID_EXPLANATION.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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
View 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
View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
};
}

View 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
};
}

View 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
};
}

View 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
View 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
View 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
View 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
View 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
View 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')
}
}
})