feat(ui): полировка - адаптивный дизайн, рекорды, touch events
- Touch events для мобильных устройств - Таблица рекордов (localStorage, top-10) - Ввод имени при новом рекорде - Отображение рейтинга с датами - CSS-переходы и анимации: - fadeIn для overlays - slideIn для модальных окон - hover-эффекты для кнопок - Адаптивный дизайн: - Масштабирование canvas под экран - Media queries для мобильных - Оптимизация UI для touch Refs: [GAM-10](/GAM/issues/GAM-10) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -6,38 +6,45 @@ Canvas-компонент игрового поля ColorLine98
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { Game, type GameState, type BallColor, BOARD_SIZE, COLORS } from '../game'
|
||||
|
||||
const CELL_SIZE = 50
|
||||
const BALL_RADIUS = 18
|
||||
const BOARD_PADDING = 10
|
||||
const STORAGE_KEY = 'colorline98_highscores'
|
||||
const MAX_HIGHSCORES = 10
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const game = new Game()
|
||||
const state = ref<GameState>(game.getState())
|
||||
const selectedPath = ref<{ row: number; col: number }[] | null>(null)
|
||||
const animatingBall = ref<{
|
||||
color: BallColor
|
||||
path: { row: number; col: number }[]
|
||||
progress: number
|
||||
} | null>(null)
|
||||
const showHighscores = ref(false)
|
||||
const highscores = ref<{ name: string; score: number; date: string }[]>([])
|
||||
const playerName = ref('')
|
||||
const showNameInput = ref(false)
|
||||
|
||||
let animationFrame: number | null = null
|
||||
let lastScore = 0
|
||||
|
||||
onMounted(() => {
|
||||
loadHighscores()
|
||||
game.subscribe((newState) => {
|
||||
if (newState.gameOver && !state.value.gameOver && state.value.score > 0) {
|
||||
checkHighscore(state.value.score)
|
||||
}
|
||||
state.value = newState
|
||||
selectedPath.value = null
|
||||
lastScore = newState.score
|
||||
})
|
||||
game.startNewGame()
|
||||
render()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -48,6 +55,67 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function loadHighscores(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
highscores.value = JSON.parse(saved)
|
||||
}
|
||||
} catch {
|
||||
highscores.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function saveHighscores(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(highscores.value))
|
||||
} catch (e) {
|
||||
console.error('Failed to save highscores', e)
|
||||
}
|
||||
}
|
||||
|
||||
function checkHighscore(score: number): void {
|
||||
if (
|
||||
highscores.value.length < MAX_HIGHSCORES ||
|
||||
score > highscores.value[highscores.value.length - 1].score
|
||||
) {
|
||||
showNameInput.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function submitScore(): void {
|
||||
if (!playerName.value.trim()) return
|
||||
|
||||
const newScore = {
|
||||
name: playerName.value.trim(),
|
||||
score: lastScore,
|
||||
date: new Date().toLocaleDateString('ru-RU'),
|
||||
}
|
||||
|
||||
highscores.value.push(newScore)
|
||||
highscores.value.sort((a, b) => b.score - a.score)
|
||||
highscores.value = highscores.value.slice(0, MAX_HIGHSCORES)
|
||||
saveHighscores()
|
||||
|
||||
playerName.value = ''
|
||||
showNameInput.value = false
|
||||
showHighscores.value = true
|
||||
}
|
||||
|
||||
function skipScore(): void {
|
||||
showNameInput.value = false
|
||||
}
|
||||
|
||||
const canvasDisplaySize = computed(() => {
|
||||
const maxSize = Math.min(window.innerWidth - 40, 480)
|
||||
const size = BOARD_SIZE * CELL_SIZE + BOARD_PADDING * 2
|
||||
return Math.min(size, maxSize)
|
||||
})
|
||||
|
||||
function handleResize(): void {
|
||||
render()
|
||||
}
|
||||
|
||||
function getCanvasSize(): number {
|
||||
return BOARD_SIZE * CELL_SIZE + BOARD_PADDING * 2
|
||||
}
|
||||
@@ -66,15 +134,14 @@ function render(): void {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const scale = canvasDisplaySize.value / getCanvasSize()
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0)
|
||||
|
||||
ctx.clearRect(0, 0, getCanvasSize(), getCanvasSize())
|
||||
|
||||
drawGrid(ctx)
|
||||
drawBalls(ctx)
|
||||
drawSelection(ctx)
|
||||
|
||||
if (animatingBall.value) {
|
||||
drawAnimatingBall(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D): void {
|
||||
@@ -135,24 +202,6 @@ function drawSelection(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawAnimatingBall(ctx: CanvasRenderingContext2D): void {
|
||||
if (!animatingBall.value) return
|
||||
|
||||
const { path, progress, color } = animatingBall.value
|
||||
const pathIndex = Math.floor(progress)
|
||||
const pathProgress = progress - pathIndex
|
||||
|
||||
if (pathIndex >= path.length - 1) return
|
||||
|
||||
const from = getCellCenter(path[pathIndex].row, path[pathIndex].col)
|
||||
const to = getCellCenter(path[pathIndex + 1].row, path[pathIndex + 1].col)
|
||||
|
||||
const x = from.x + (to.x - from.x) * pathProgress
|
||||
const y = from.y + (to.y - from.y) * pathProgress
|
||||
|
||||
drawBall(ctx, x, y, color)
|
||||
}
|
||||
|
||||
function lightenColor(hex: string, percent: number): string {
|
||||
const num = parseInt(hex.slice(1), 16)
|
||||
const amt = Math.round(2.55 * percent)
|
||||
@@ -171,9 +220,16 @@ function darkenColor(hex: string, percent: number): string {
|
||||
return `#${((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1)}`
|
||||
}
|
||||
|
||||
function getCellFromPoint(x: number, y: number): { row: number; col: number } | null {
|
||||
const col = Math.floor((x - BOARD_PADDING) / CELL_SIZE)
|
||||
const row = Math.floor((y - BOARD_PADDING) / CELL_SIZE)
|
||||
function getCellFromPoint(clientX: number, clientY: number): { row: number; col: number } | null {
|
||||
if (!canvasRef.value) return null
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const scale = canvasDisplaySize.value / getCanvasSize()
|
||||
const x = (clientX - rect.left) / scale - BOARD_PADDING
|
||||
const y = (clientY - rect.top) / scale - BOARD_PADDING
|
||||
|
||||
const col = Math.floor(x / CELL_SIZE)
|
||||
const row = Math.floor(y / CELL_SIZE)
|
||||
|
||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
||||
return null
|
||||
@@ -182,22 +238,30 @@ function getCellFromPoint(x: number, y: number): { row: number; col: number } |
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent): void {
|
||||
if (!canvasRef.value || animatingBall.value) return
|
||||
function handleInteraction(clientX: number, clientY: number): void {
|
||||
if (state.value.gameOver) return
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
|
||||
const cell = getCellFromPoint(x, y)
|
||||
const cell = getCellFromPoint(clientX, clientY)
|
||||
if (!cell) return
|
||||
|
||||
game.selectCell(cell.row, cell.col)
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent): void {
|
||||
handleInteraction(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function handleTouch(event: TouchEvent): void {
|
||||
event.preventDefault()
|
||||
const touch = event.touches[0]
|
||||
if (touch) {
|
||||
handleInteraction(touch.clientX, touch.clientY)
|
||||
}
|
||||
}
|
||||
|
||||
function startNewGame(): void {
|
||||
if (animatingBall.value) return
|
||||
game.startNewGame()
|
||||
showHighscores.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -223,19 +287,63 @@ function startNewGame(): void {
|
||||
:width="getCanvasSize()"
|
||||
:height="getCanvasSize()"
|
||||
class="game-board"
|
||||
:style="{ width: canvasDisplaySize + 'px', height: canvasDisplaySize + 'px' }"
|
||||
@click="handleClick"
|
||||
@touchstart="handleTouch"
|
||||
/>
|
||||
|
||||
<div v-if="state.gameOver" class="game-over">
|
||||
<div v-if="state.gameOver && !showNameInput && !showHighscores" class="game-over">
|
||||
<div class="game-over-content">
|
||||
<h2>Игра окончена!</h2>
|
||||
<p>Ваш счёт: {{ state.score }}</p>
|
||||
<button @click="startNewGame" class="btn">Играть ещё</button>
|
||||
<div class="game-over-buttons">
|
||||
<button @click="startNewGame" class="btn">Играть ещё</button>
|
||||
<button @click="showHighscores = true" class="btn btn-secondary">Рекорды</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showNameInput" class="game-over">
|
||||
<div class="game-over-content name-input-content">
|
||||
<h2>Новый рекорд!</h2>
|
||||
<p>Ваш счёт: {{ lastScore }}</p>
|
||||
<input
|
||||
v-model="playerName"
|
||||
type="text"
|
||||
placeholder="Введите имя"
|
||||
class="name-input"
|
||||
maxlength="20"
|
||||
@keyup.enter="submitScore"
|
||||
/>
|
||||
<div class="game-over-buttons">
|
||||
<button @click="submitScore" class="btn" :disabled="!playerName.trim()">Сохранить</button>
|
||||
<button @click="skipScore" class="btn btn-secondary">Пропустить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showHighscores" class="game-over">
|
||||
<div class="game-over-content highscores-content">
|
||||
<h2>Таблица рекордов</h2>
|
||||
<div class="highscores-list">
|
||||
<div v-if="highscores.length === 0" class="no-scores">Пока нет рекордов</div>
|
||||
<div v-for="(score, index) in highscores" :key="index" class="highscore-row">
|
||||
<span class="rank">{{ index + 1 }}.</span>
|
||||
<span class="name">{{ score.name }}</span>
|
||||
<span class="score-value">{{ score.score }}</span>
|
||||
<span class="date">{{ score.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="game-over-buttons">
|
||||
<button @click="startNewGame" class="btn">Новая игра</button>
|
||||
<button @click="showHighscores = false" class="btn btn-secondary">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-controls">
|
||||
<button @click="startNewGame" class="btn">Новая игра</button>
|
||||
<button @click="showHighscores = true" class="btn btn-secondary">Рекорды</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -247,10 +355,13 @@ function startNewGame(): void {
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -260,6 +371,7 @@ function startNewGame(): void {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.score {
|
||||
@@ -269,6 +381,7 @@ function startNewGame(): void {
|
||||
|
||||
.score-value {
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.next-balls {
|
||||
@@ -284,6 +397,7 @@ function startNewGame(): void {
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: inset -2px -2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
@@ -292,6 +406,7 @@ function startNewGame(): void {
|
||||
background: #ecf0f1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
@@ -305,6 +420,16 @@ function startNewGame(): void {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-over-content {
|
||||
@@ -313,6 +438,20 @@ function startNewGame(): void {
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
max-width: 90vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-over-content h2 {
|
||||
@@ -323,12 +462,23 @@ function startNewGame(): void {
|
||||
|
||||
.game-over-content p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.game-over-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -353,4 +503,144 @@ function startNewGame(): void {
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);
|
||||
}
|
||||
|
||||
.name-input-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
font-size: 18px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 25px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.name-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.highscores-content {
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.highscores-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.no-scores {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.highscore-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.highscore-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.highscore-row .rank {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
min-width: 25px;
|
||||
}
|
||||
|
||||
.highscore-row .name {
|
||||
flex: 1;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highscore-row .score-value {
|
||||
font-weight: bold;
|
||||
color: #27ae60;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.highscore-row .date {
|
||||
font-size: 12px;
|
||||
color: #95a5a6;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.game-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.next-balls {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-ball {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.game-over-content {
|
||||
padding: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.game-over-content h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.game-over-content p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.highscore-row .date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user