前端小游戏-五子棋

171 阅读5分钟

 在本篇内容,我们做一个有一个有意思的小游戏,五子棋

一、环境配置

  • vue3
  • typescript
  • scss
  • vite

二、项目结构

重要文件讲解

  1. GomokuBoard.vue:五子棋的棋盘
  2. useGomoku.ts:数据处理文件,比如交错下棋,悔棋,重新下子
  3. global.scss:全局样式文件,这个不是棋盘的样式,而是整个页面的样式
  4. 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>