大家都玩过贪吃蛇小游戏,控制一条蛇去吃食物,然后蛇在吃到食物后会变大。本篇博客将会实现贪吃蛇小游戏的功能。

1.实现效果

前端实现贪吃蛇功能-LMLPHP

2.整体布局

/**
 * 游戏区域样式
 */
const gameBoardStyle = {
    gridTemplateColumns: `repeat(${width}, 1fr)`,
    gridTemplateRows: `repeat(${height}, 1fr)`,
    display: 'grid',
    border: '1px solid #000',
    width: '500px',
    height: '500px',
    backgroundColor: '#488cfa'
};

/**
 * 小蛇样式
 */
const snakeBodyStyle = (segment) => ({
    gridRowStart: segment.y,
    gridColumnStart: segment.x,
    backgroundColor: 'green'
})

/**
 * 食物样式
 */
const foodStyle = {
    gridRowStart: food.current.y,
    gridColumnStart: food.current.x,
    backgroundColor: 'red'
}

<div className={'snake-game'}>
    <div className={'game-board'} style={gameBoardStyle}>
       {/*蛇身体*/}
       {snake.map((segment, idx) =>
          <div key={idx} className={'snake-body'} style={snakeBodyStyle(segment)}/>
          )
       }
       {/*食物*/}
       <div className={'food'} style={foodStyle}></div>
     </div>

</div>
        

        采用grid 布局,整个游戏区域划分为width*height个小块,小蛇身体的每一部分对应一小块,食物对应一小块。

3.技术实现

a.数据结构

        小蛇的数据结构是个坐标数组,snake[0]是蛇头,snake[snake.length-1]是蛇尾巴。snake[i].x表示第i块位置的x坐标,snake[i].y表示第i块位置的y坐标。

        食物的数据结构是坐标。

        游戏区域是一个width*height的虚拟空间。

b.场景

一、小蛇如何移动,以及移动方式

 1. 通过设置监听键盘的上下左右事件,来触发小蛇的移动。

 2. 通过定时器实现小蛇沿着当前方向移动

         

// 移动方向,上下左右
const directions = [[0, -1], [0, 1], [-1, 0], [1, 0]];
// 当前移动方向
const [currentDirection, setCurrentDirection] = useState(3);

// 小蛇移动
function move() {
    const direction = directions[currentDirection];
    // 更新上一次蛇尾巴
    lastTail.current = {x: snake[snake.length - 1].x, y: snake[snake.length - 1].y};
    const head = snake[0];
    // 移动小蛇,将数组后移动
    for (let i = snake.length - 1; i > 0; i--) {
        snake[i].x = snake[i - 1].x;
        snake[i].y = snake[i - 1].y;
    }
    // 更新蛇头
    head.x += direction[0];
    head.y += direction[1];
    // 触发渲染
    setSnake([...snake]);
}



const [click, setClick] = useState(0)
// 设置键盘监听函数
useEffect(() => {
    document.addEventListener('keydown', function (event) {
        const key = event.key;
        if (key === 'ArrowUp') {
            // 监听到了向上箭头键的按下操作
            setCurrentDirection(0)
            setClick((c)=>c+1);
        } else if (key === 'ArrowDown') {
            // 监听到了向下箭头键的按下操作
            setCurrentDirection(1)
            setClick((c)=>c+1);
        } else if (key === 'ArrowLeft') {
            // 监听到了向左箭头键的按下操作
            setCurrentDirection(2)
            setClick((c)=>c+1);
        } else if (key === 'ArrowRight') {
            // 监听到了向右箭头键的按下操作
            setCurrentDirection(3)
            setClick((c)=>c+1);
        }
    });
}, [])


/**
 * 设定定时器,每1s向当前方向移动小蛇
 * 如果敲键盘,或者吃到食物需要更新定时器
 * tips: 吃到食物更新是因为定时器晚执行可能会有并发问题
 */
useEffect(() => {
    console.log(click)
    move()
    const timer = setInterval(() => {
        move();
    }, 1000);
    return () => {
        clearInterval(timer);
    };
}, [click, snake.length]);
二、游戏结束判断

1.游戏成功判断,若无发生成新的食物,则游戏成功

2.游戏失败判断,若小蛇出边界或者小蛇撞到自己,则游戏失败。

// 每次渲染后,判断小蛇状态
useEffect(() => {

    // 判断小蛇撞出边界
    if (head.x < 0 || head.x >= width || head.y < 0 || head.y >= height) {
        console.log('游戏失败')
        alert('出界,游戏失败');
        reset();
        return;
    }

    // 判断小蛇撞到自己
    for (let i = 1; i < snake.length; i++) {
        if (head.x === snake[i].x && head.y === snake[i].y) {
            console.log('游戏失败')
            console.log('snake:' + JSON.stringify(snake))
            alert('撞到自己了,游戏失败');
            reset();
            return;
        }
    }

})
三、食物生成以及吃食物操作

 1.食物需要在区域内随机生成,并且不能生成在小蛇身体上,若无地方生成,则游戏通关。

 2.吃食物操作会增长小蛇的长度,在小蛇的尾巴添加一截,需要存储前一个路径的尾巴位置。

// 随机生成食物
function generateFood(snake) {
    const x = Math.floor(Math.random() * width);
    const y = Math.floor(Math.random() * height);
    // 如果蛇长等于宽高,说明蛇占满了整个区域,已成功
    if (snake.length === width * height) {
        return null;
    }

    // 判断食物是否在蛇身上
    for (let node of snake) {
        if (node.x === x && node.y === y) {
            // 重新生成食物,
            return generateFood(snake);
        }
    }
    return {x, y};
}



// 蛇尾巴
const lastTail = useRef(null);

// 每次渲染后,判断小蛇状态
useEffect(() => {
    const head = snake[0];
    // 小蛇吃到食物
    if (head.x === food.current.x && head.y === food.current.y) {
        console.log('eat food!')
        // 添加上次蛇尾巴
        let nTail = {...lastTail.current};
        snake.push(nTail);
        lastTail.current = nTail;
        // 重新生成食物
        food.current = generateFood(snake);
        if (food.current === null) {
            console.log('恭喜已通过')
            alert('恭喜已经通关');
            reset();
            return;
        }
        // 发起渲染
        console.log('newsnake:' + JSON.stringify(snake))
        setSnake([...snake]);
        return;
    }
});


c.整体代码

const {useState, useRef, useEffect} = require("react");

const Snake = ({width, height}) => {
    // 移动方向,上下左右
    const directions = [[0, -1], [0, 1], [-1, 0], [1, 0]];
    // 当前移动方向
    const [currentDirection, setCurrentDirection] = useState(3);

    // 初始小蛇
    const initialSnake = [{
        x: 0, // pos x
        y: 0, // pos y
    }];

    // 蛇身体
    const [snake, setSnake] = useState(initialSnake);

    // 食物
    const food = useRef(null);
    // 初始化食物
    if (food.current === null) {
        food.current = generateFood(snake);
    }

    // 随机生成食物
    function generateFood(snake) {
        const x = Math.floor(Math.random() * width);
        const y = Math.floor(Math.random() * height);
        // 如果蛇长等于宽高,说明蛇占满了整个区域,已成功
        if (snake.length === width * height) {
            return null;
        }

        // 判断食物是否在蛇身上
        for (let node of snake) {
            if (node.x === x && node.y === y) {
                // 重新生成食物,
                return generateFood(snake);
            }
        }
        return {x, y};
    }

    // 蛇尾巴
    const lastTail = useRef(null);

    // 小蛇移动
    function move() {
        const direction = directions[currentDirection];
        // 更新蛇尾巴
        lastTail.current = {x: snake[snake.length - 1].x, y: snake[snake.length - 1].y};
        const head = snake[0];

        for (let i = snake.length - 1; i > 0; i--) {
            snake[i].x = snake[i - 1].x;
            snake[i].y = snake[i - 1].y;
        }
        head.x += direction[0];
        head.y += direction[1];
        setSnake([...snake]);
    }

    // 游戏结束后重置
    function reset() {
        setSnake([...initialSnake]);
        setCurrentDirection(3);
        lastTail.current = null;
    }

    // 判断是否游戏结束
    useEffect(() => {
        const head = snake[0];
        // 判断小蛇撞出边界
        if (head.x < 0 || head.x >= width || head.y < 0 || head.y >= height) {
            console.log('游戏失败')
            alert('出界,游戏失败');
            reset();
            return;
        }

        // 判断小蛇撞到自己
        for (let i = 1; i < snake.length; i++) {
            if (head.x === snake[i].x && head.y === snake[i].y) {
                console.log('游戏失败')
                console.log('snake:' + JSON.stringify(snake))
                alert('撞到自己了,游戏失败');
                reset();
                return;
            }
        }

    })
    
    // 判断是否吃到食物
    useEffect(()=>{
        const head = snake[0];
        // 小蛇吃到食物
        if (head.x === food.current.x && head.y === food.current.y) {
            console.log('eat food!')
            // 添加上次蛇尾巴
            let nTail = {...lastTail.current};
            snake.push(nTail);
            lastTail.current = nTail;
            // 重新生成食物
            food.current = generateFood(snake);
            if (food.current === null) {
                console.log('恭喜已通过')
                alert('恭喜已经通关');
                reset();
                return;
            }
            // 发起渲染
            console.log('newsnake:' + JSON.stringify(snake))
            setSnake([...snake]);
            return;
        }
    })

    const [click, setClick] = useState(0)
    // 设置键盘监听函数
    useEffect(() => {
        document.addEventListener('keydown', function (event) {
            const key = event.key;
            if (key === 'ArrowUp') {
                // 监听到了向上箭头键的按下操作
                setCurrentDirection(0)
                setClick((c)=>c+1);
            } else if (key === 'ArrowDown') {
                // 监听到了向下箭头键的按下操作
                setCurrentDirection(1)
                setClick((c)=>c+1);
            } else if (key === 'ArrowLeft') {
                // 监听到了向左箭头键的按下操作
                setCurrentDirection(2)
                setClick((c)=>c+1);
            } else if (key === 'ArrowRight') {
                // 监听到了向右箭头键的按下操作
                setCurrentDirection(3)
                setClick((c)=>c+1);
            }
        });
    }, [])

    /**
     * 设定定时器,每1s向当前方向移动小蛇
     * 如果敲键盘,或者吃到食物需要更新定时器
     * tips: 吃到食物,由于定时器晚执行,可能会用老的state覆盖
     */
    useEffect(() => {
        console.log(click)
        move()
        const timer = setInterval(() => {
            move();
        }, 1000);
        return () => {
            clearInterval(timer);
        };
    }, [click, snake.length]);


/**
 * 游戏区域样式
 */
const gameBoardStyle = {
    gridTemplateColumns: `repeat(${width}, 1fr)`,
    gridTemplateRows: `repeat(${height}, 1fr)`,
    display: 'grid',
    border: '1px solid #000',
    width: '500px',
    height: '500px',
    backgroundColor: '#488cfa'
};

/**
 * 小蛇样式
 */
const snakeBodyStyle = (segment) => ({
    gridRowStart: segment.y,
    gridColumnStart: segment.x,
    backgroundColor: 'green'
})

/**
 * 食物样式
 */
const foodStyle = {
    gridRowStart: food.current.y,
    gridColumnStart: food.current.x,
    backgroundColor: 'red'
}

    // 小蛇组成
    return (
        <>
            <div className={'snake-game'}>
                <div className={'game-board'} style={gameBoardStyle}>
                    {/*蛇身体*/}
                    {snake.map((segment, idx) =>
                        <div key={idx} className={'snake-body'} style={snakeBodyStyle(segment)}/>
                    )
                    }
                    {/*食物*/}
                    <div className={'food'}
                         style={foodStyle}>
                    </div>
                </div>

            </div>
        </>
    )
}

export default Snake
01-20 18:44