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:
59
src/App.vue
59
src/App.vue
@@ -1,7 +1,62 @@
|
|||||||
|
<!--
|
||||||
|
@author aevgarik@gmail.com
|
||||||
|
@date 2026-03-22
|
||||||
|
|
||||||
|
Главный компонент приложения ColorLine98
|
||||||
|
-->
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
import GameBoard from './components/GameBoard.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelloWorld />
|
<div class="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>ColorLine98</h1>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<GameBoard />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
356
src/components/GameBoard.vue
Normal file
356
src/components/GameBoard.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user