feat(ui): реализация Canvas-рендеринга и UI для ColorLine98

- Canvas 2D компонент GameBoard.vue:
  - Отрисовка поля 9×9 с сеткой
  - Отрисовка шаров (7 цветов с градиентами)
  - Клик-выделение шара (оранжевая обводка)
  - Клик-перемещение в целевую клетку
  - Панель очков (текущий счёт)
  - Превью следующих 3 шаров (цветные круги)
  - Кнопка "Новая игра"
  - Экран Game Over с финальным счётом

- Обновлён App.vue:
  - Заголовок ColorLine98
  - Градиентный фон
  - Центрированный layout

Refs: [GAM-9](/GAM/issues/GAM-9)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
aevgarik
2026-03-22 21:50:46 +03:00
parent 7c987be2c1
commit 2f0d36352c
2 changed files with 413 additions and 2 deletions

View File

@@ -1,7 +1,62 @@
<!--
@author aevgarik@gmail.com
@date 2026-03-22
Главный компонент приложения ColorLine98
-->
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import GameBoard from './components/GameBoard.vue'
</script>
<template>
<HelloWorld />
<div class="app">
<header class="app-header">
<h1>ColorLine98</h1>
</header>
<main class="app-main">
<GameBoard />
</main>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.app-header {
padding: 20px;
text-align: center;
}
.app-header h1 {
color: white;
font-size: 48px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
letter-spacing: 3px;
}
.app-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,356 @@
<!--
@author aevgarik@gmail.com
@date 2026-03-22
Canvas-компонент игрового поля ColorLine98
-->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } 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 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)
let animationFrame: number | null = null
onMounted(() => {
game.subscribe((newState) => {
state.value = newState
selectedPath.value = null
})
game.startNewGame()
render()
})
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
})
watch(
state,
() => {
render()
},
{ deep: true }
)
function getCanvasSize(): number {
return BOARD_SIZE * CELL_SIZE + BOARD_PADDING * 2
}
function getCellCenter(row: number, col: number): { x: number; y: number } {
return {
x: BOARD_PADDING + col * CELL_SIZE + CELL_SIZE / 2,
y: BOARD_PADDING + row * CELL_SIZE + CELL_SIZE / 2,
}
}
function render(): void {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
drawGrid(ctx)
drawBalls(ctx)
drawSelection(ctx)
if (animatingBall.value) {
drawAnimatingBall(ctx)
}
}
function drawGrid(ctx: CanvasRenderingContext2D): void {
ctx.strokeStyle = '#BDC3C7'
ctx.lineWidth = 1
for (let i = 0; i <= BOARD_SIZE; i++) {
const pos = BOARD_PADDING + i * CELL_SIZE
ctx.beginPath()
ctx.moveTo(pos, BOARD_PADDING)
ctx.lineTo(pos, BOARD_PADDING + BOARD_SIZE * CELL_SIZE)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(BOARD_PADDING, pos)
ctx.lineTo(BOARD_PADDING + BOARD_SIZE * CELL_SIZE, pos)
ctx.stroke()
}
}
function drawBalls(ctx: CanvasRenderingContext2D): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const ball = state.value.board[row][col].ball
if (ball !== null) {
const { x, y } = getCellCenter(row, col)
drawBall(ctx, x, y, ball)
}
}
}
}
function drawBall(ctx: CanvasRenderingContext2D, x: number, y: number, color: BallColor): void {
const gradient = ctx.createRadialGradient(x - 5, y - 5, 0, x, y, BALL_RADIUS)
gradient.addColorStop(0, lightenColor(COLORS[color], 30))
gradient.addColorStop(1, COLORS[color])
ctx.beginPath()
ctx.arc(x, y, BALL_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = gradient
ctx.fill()
ctx.strokeStyle = darkenColor(COLORS[color], 20)
ctx.lineWidth = 2
ctx.stroke()
}
function drawSelection(ctx: CanvasRenderingContext2D): void {
if (!state.value.selectedCell) return
const { row, col } = state.value.selectedCell
const { x, y } = getCellCenter(row, col)
ctx.beginPath()
ctx.arc(x, y, BALL_RADIUS + 3, 0, Math.PI * 2)
ctx.strokeStyle = '#F39C12'
ctx.lineWidth = 3
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)
const R = Math.min(255, (num >> 16) + amt)
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt)
const B = Math.min(255, (num & 0x0000ff) + amt)
return `#${((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1)}`
}
function darkenColor(hex: string, percent: number): string {
const num = parseInt(hex.slice(1), 16)
const amt = Math.round(2.55 * percent)
const R = Math.max(0, (num >> 16) - amt)
const G = Math.max(0, ((num >> 8) & 0x00ff) - amt)
const B = Math.max(0, (num & 0x0000ff) - amt)
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)
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return null
}
return { row, col }
}
function handleClick(event: MouseEvent): void {
if (!canvasRef.value || animatingBall.value) return
const rect = canvasRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const cell = getCellFromPoint(x, y)
if (!cell) return
game.selectCell(cell.row, cell.col)
}
function startNewGame(): void {
if (animatingBall.value) return
game.startNewGame()
}
</script>
<template>
<div class="game-container">
<div class="game-header">
<div class="score">
Счёт: <span class="score-value">{{ state.score }}</span>
</div>
<div class="next-balls">
Следующие:
<span
v-for="(ball, index) in state.nextBalls"
:key="index"
class="preview-ball"
:style="{ backgroundColor: COLORS[ball] }"
/>
</div>
</div>
<canvas
ref="canvasRef"
:width="getCanvasSize()"
:height="getCanvasSize()"
class="game-board"
@click="handleClick"
/>
<div v-if="state.gameOver" class="game-over">
<div class="game-over-content">
<h2>Игра окончена!</h2>
<p>Ваш счёт: {{ state.score }}</p>
<button @click="startNewGame" class="btn">Играть ещё</button>
</div>
</div>
<div class="game-controls">
<button @click="startNewGame" class="btn">Новая игра</button>
</div>
</div>
</template>
<style scoped>
.game-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 480px;
margin-bottom: 20px;
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
color: white;
}
.score {
font-size: 18px;
font-weight: bold;
}
.score-value {
font-size: 24px;
}
.next-balls {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.preview-ball {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
box-shadow: inset -2px -2px 4px rgba(0, 0, 0, 0.2);
}
.game-board {
border: 3px solid #34495e;
border-radius: 8px;
background: #ecf0f1;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.game-over {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.game-over-content {
background: white;
padding: 40px;
border-radius: 15px;
text-align: center;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.game-over-content h2 {
color: #e74c3c;
margin-bottom: 20px;
font-size: 28px;
}
.game-over-content p {
font-size: 20px;
margin-bottom: 30px;
color: #2c3e50;
}
.game-controls {
margin-top: 20px;
}
.btn {
padding: 12px 30px;
font-size: 16px;
font-weight: bold;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 25px;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
</style>