在本篇内容,我们做一个有一个有意思的小游戏,五子棋
一、环境配置
- vue3
- typescript
- scss
- vite
二、项目结构
重要文件讲解
- GomokuBoard.vue:五子棋的棋盘
- useGomoku.ts:数据处理文件,比如交错下棋,悔棋,重新下子
- global.scss:全局样式文件,这个不是棋盘的样式,而是整个页面的样式
- App.vue:将棋盘,右侧状态,上方的功能按钮组合在一起的部件
三、代码
GomokuBoard.vue
<template>
<div class="gomoku-board" :style="boardStyle">
<div v-for="(row, y) in board" :key="y" class="row">
<div
v-for="(cell, x) in row"
:key="x"
class="cell"
:class="{
black: cell === Cell.Black,
white: cell === Cell.White,
last: lastMove && lastMove.x === x && lastMove.y === y,
win: winningLine.some((p) => p.x === x && p.y === y)
}"
@click="handleClick(x, y)"
></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useGomoku, Cell } from '../composables/useGomoku'
const props = defineProps<{ size?: number }>()
const game = useGomoku({ size: props.size ?? 15, blackFirst: true })
const { board, place, lastMove, winningLine } = game
function handleClick(x: number, y: number) {
place(x, y)
}
const boardStyle = computed(() => ({
'--n': game.size.value,
'--cell': 'var(--cell-size)',
'--pad': 'var(--board-pad)'
}))
</script>
<style lang="scss" scoped>
.gomoku-board {
display: inline-grid;
grid-template-rows: repeat(var(--n), var(--cell));
grid-template-columns: repeat(var(--n), var(--cell));
background: var(--board-bg);
padding: var(--pad);
border: 2px solid var(--line-color);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
border-radius: 12px;
position: relative;
}
.row {
display: contents;
}
.cell {
border: 1px solid var(--line-color);
position: relative;
cursor: pointer;
transition: background 0.15s ease;
}
.cell::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.2);
}
.cell.black::after {
background: var(--black);
}
.cell.white::after {
background: var(--white);
border: 1px solid #ccc;
}
.cell.last::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
z-index: 1;
}
.cell.win::after {
outline: 3px solid var(--win);
}
</style>
useGomoku.ts
import { ref, computed } from 'vue'
export enum Cell {
Empty = 0,
Black = 1,
White = 2
}
export type Point = { x: number; y: number }
export type Move = { x: number; y: number; color: Cell }
export interface GameOptions {
size?: number // 棋盘大小
blackFirst?: boolean // 是否黑先
}
export function useGomoku(options: GameOptions = {}) {
const size = ref(options.size ?? 15)
const board = ref<Cell[][]>(
Array.from({ length: size.value }, () => Array(size.value).fill(Cell.Empty))
)
const blackTurn = ref(options.blackFirst ?? true)
const winner = ref<Cell.Empty | Cell.Black | Cell.White>(Cell.Empty)
const winningLine = ref<Point[]>([])
const lastMove = ref<Point | null>(null)
const history = ref<Move[]>([])
const future = ref<Move[]>([]) // 用于重做
const turnColor = computed(() => (blackTurn.value ? Cell.Black : Cell.White))
const movesCount = computed(() => history.value.length)
function inBounds(x: number, y: number) {
return x >= 0 && y >= 0 && x < size.value && y < size.value
}
function reset(newSize?: number) {
if (newSize && newSize !== size.value) size.value = newSize
board.value = Array.from({ length: size.value }, () => Array(size.value).fill(Cell.Empty))
blackTurn.value = options.blackFirst ?? true
winner.value = Cell.Empty
winningLine.value = []
lastMove.value = null
history.value = []
future.value = []
}
function place(x: number, y: number): boolean {
if (!inBounds(x, y) || board.value[y][x] !== Cell.Empty || winner.value !== Cell.Empty)
return false
const color = turnColor.value
board.value[y][x] = color
lastMove.value = { x, y }
history.value.push({ x, y, color })
future.value = [] // 新落子后清空重做栈
if (checkWin(x, y, color)) {
winner.value = color
} else {
blackTurn.value = !blackTurn.value
}
return true
}
function undo() {
if (!history.value.length) return
const m = history.value.pop()!
board.value[m.y][m.x] = Cell.Empty
future.value.push(m)
lastMove.value = history.value.length
? { x: history.value.at(-1)!.x, y: history.value.at(-1)!.y }
: null
blackTurn.value = m.color === Cell.Black ? true : false // 回到执行这步之前的执方
winner.value = Cell.Empty
winningLine.value = []
}
function redo() {
if (!future.value.length || winner.value !== Cell.Empty) return
const m = future.value.pop()!
if (board.value[m.y][m.x] !== Cell.Empty) return
board.value[m.y][m.x] = m.color
lastMove.value = { x: m.x, y: m.y }
history.value.push(m)
if (checkWin(m.x, m.y, m.color)) {
winner.value = m.color
} else {
blackTurn.value = m.color === Cell.Black ? false : true
}
}
// 简单 AI:随机选择一个空位
function aiMoveRandom(): boolean {
if (winner.value !== Cell.Empty) return false
const empties: Point[] = []
for (let y = 0; y < size.value; y++) {
for (let x = 0; x < size.value; x++) {
if (board.value[y][x] === Cell.Empty) empties.push({ x, y })
}
}
if (!empties.length) return false
const pick = empties[Math.floor(Math.random() * empties.length)]
return place(pick.x, pick.y)
}
function checkWin(x: number, y: number, color: Cell): boolean {
const dirs = [
{ dx: 1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 1, dy: 1 },
{ dx: 1, dy: -1 }
]
for (const { dx, dy } of dirs) {
let count = 1
const line: Point[] = [{ x, y }]
// 正向
let nx = x + dx,
ny = y + dy
while (inBounds(nx, ny) && board.value[ny][nx] === color) {
line.push({ x: nx, y: ny })
count++
nx += dx
ny += dy
}
// 反向
nx = x - dx
ny = y - dy
while (inBounds(nx, ny) && board.value[ny][nx] === color) {
line.unshift({ x: nx, y: ny })
count++
nx -= dx
ny -= dy
}
if (count >= 5) {
winningLine.value = line.slice(0, 5)
return true
}
}
return false
}
return {
size,
board,
blackTurn,
winner,
winningLine,
lastMove,
movesCount,
turnColor,
history,
future,
reset,
place,
undo,
redo,
aiMoveRandom
}
}
global.scss
/* global.scss
通用样式与主题变量,供 Gomoku 项目使用(Vue3 + TS + SCSS)
*/
:root {
/* 主题色与棋盘变量 */
--board-bg: #e9c46a; /* 棋盘木纹色 */
--line-color: #333;
--black: #111;
--white: #f7f7f7;
--accent: #2a9d8f;
--win: #e76f51;
/* 可用于棋盘组件的默认尺寸(可在组件中覆盖) */
--cell-size: 36px;
--board-pad: 14px;
}
/* Reset / base */
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family:
ui-sans-serif,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
'Apple Color Emoji',
'Segoe UI Emoji';
background: #fafafa;
color: #222;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.45;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
/* 布局容器 */
.container {
max-width: 1100px;
margin: 24px auto;
padding: 0 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.header .title {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* 工具栏与控件 */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button {
appearance: none;
border: 1px solid #ddd;
background: white;
padding: 8px 12px;
border-radius: 12px;
cursor: pointer;
transition:
transform 0.05s ease,
box-shadow 0.15s ease;
font-weight: 600;
}
.button:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
.button:active {
transform: translateY(1px);
}
.button[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.select,
.checkbox {
border: 1px solid #ddd;
background: white;
padding: 8px 12px;
border-radius: 12px;
font-weight: 600;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px dashed #ddd;
background: #fff;
font-weight: 700;
}
.badge .dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.footer {
margin-top: 16px;
color: #666;
font-size: 12px;
}
/* 棋盘容器布局 */
.board-wrap {
display: grid;
grid-template-columns: auto 1fr;
gap: 16px;
align-items: start;
}
.sidebar {
display: grid;
gap: 8px;
align-content: start;
background: #fff;
border: 1px solid #eee;
padding: 12px;
border-radius: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05);
}
.sidebar h3 {
margin: 4px 0 8px;
font-size: 14px;
color: #444;
}
.move-list {
max-height: 360px;
overflow: auto;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 12px;
}
.move-list .item {
padding: 6px 8px;
border-radius: 8px;
display: flex;
justify-content: space-between;
}
.move-list .item:nth-child(odd) {
background: #fafafa;
}
/* 小工具类 */
.flex {
display: flex;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.col {
display: flex;
flex-direction: column;
}
.hidden {
display: none !important;
}
.sr-only {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 响应式 */
@media (max-width: 900px) {
.board-wrap {
grid-template-columns: 1fr;
}
.sidebar {
order: 2;
}
}
@media (max-width: 640px) {
.container {
padding: 12px;
}
.header .title {
font-size: 18px;
}
.button,
.select {
padding: 6px 10px;
}
}
/* 下面保留少许变量说明,供组件参考(组件内可覆盖) */
/* 使用示例:
.board {
--cell: var(--cell-size);
--pad: var(--board-pad);
}
*/
App.vue
<template>
<div class="container">
<header class="header">
<div class="title">五子棋 (Gomoku)</div>
<div class="toolbar">
<button class="button" @click="reset()">重新开始</button>
<button class="button" @click="undo" :disabled="!history.length">悔棋</button>
<button class="button" @click="redo" :disabled="!future.length">重做</button>
<button class="button" @click="aiMoveRandom" :disabled="winner !== Cell.Empty">
AI 落子
</button>
</div>
</header>
<main class="board-wrap">
<GomokuBoard :size="size" />
<aside class="sidebar">
<h3>状态</h3>
<div class="badge">
<span
class="dot"
:style="{
background: turnColor === Cell.Black ? 'var(--black)' : 'var(--white)',
border: turnColor === Cell.White ? '1px solid #ccc' : 'none'
}"
></span>
{{ turnColor === Cell.Black ? '黑棋回合' : '白棋回合' }}
</div>
<div v-if="winner !== Cell.Empty" class="badge">
🏆 胜者:{{ winner === Cell.Black ? '黑棋' : '白棋' }}
</div>
<h3>棋谱 ({{ history.length }} 手)</h3>
<div class="move-list">
<div v-for="(m, i) in history" :key="i" class="item">
<span>#{{ i + 1 }} {{ m.color === Cell.Black ? '黑' : '白' }}</span>
<span>({{ m.x }}, {{ m.y }})</span>
</div>
</div>
</aside>
</main>
<footer class="footer">Vue3 + TS + SCSS 五子棋</footer>
</div>
</template>
<script lang="ts" setup>
import { Cell, useGomoku } from './composables/useGomoku'
import GomokuBoard from './components/GomokuBoard.vue'
const size = 15
const game = useGomoku({ size, blackFirst: true })
const { reset, undo, redo, aiMoveRandom, history, future, turnColor, winner } = game
</script>
<style lang="scss" scoped>
.container {
padding-top: 20px;
}
</style>