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:
aevgarik
2026-03-22 21:54:38 +03:00
parent 2f0d36352c
commit 2bdab2972e

View File

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