基类Entity
建立基类Entity,实现投影能力、动画入场效果(从小变大的弹性动画)、计算自己在地图格位置的方法。
// 导入gsap动画库(用于创建补间动画)
import gsap from 'gsap'
// 定义Entity基类
export default class Entity {
constructor(mesh, resolution, option = { size: 1.5, number: 0.5 }) {
// 保存传入的3D网格对象
this.mesh = mesh
// 配置网格的阴影属性
mesh.castShadow = true // 允许投射阴影
mesh.receiveShadow = true // 允许接收阴影
// 保存分辨率参数和相关配置选项
this.resolution = resolution // 可能包含屏幕/布局分辨率信息
this.option = option // 动画参数,默认size=1.5, number=0.5
}
// 位置属性的getter,代理访问mesh的位置
get position() {
return this.mesh.position // 返回THREE.Vector3对象
}
// 根据坐标计算索引的方法(可能用于网格布局)
getIndexByCoord() {
const { x, y } = this.resolution // 解构分辨率,可能代表网格列/行数
// 计算索引公式:z坐标 * x轴分辨率 + x坐标
// 注意:此处可能存在问题,通常3D空间索引需要考虑y坐标
return this.position.z * x + this.position.x
}
// 进入动画方法
in() {
// 使用gsap创建缩放动画
gsap.from(this.mesh.scale, { // 从指定状态动画到当前状态
duration: 1, // 动画时长1秒
x: 0, // 初始X缩放为0
y: 0, // 初始Y缩放为0
z: 0, // 初始Z缩放为0
ease: `elastic.out(${this.option.size}, ${this.option.number})`, // 弹性缓动函数
})
}
// 离开动画方法(暂未实现)
out() {}
}
开发糖果类Candy
Candy
这个类继承了之前Entity,
这里用threejs内置的SphereGeometry(0.3, 20, 20)
做了个小圆球模型。然后用MeshStandardMaterial做材质,并可以传入颜色
,所有糖果都会用这个模具和颜色来制作。并且可以随机生成大小不一的糖果,代表不同的分数。
// 导入Three.js的3D对象相关模块
import {
Mesh, // 网格对象(几何体+材质的组合)
MeshNormalMaterial, // 显示法向量的标准材质
MeshStandardMaterial, // PBR标准材质
SphereGeometry, // 球体几何体
} from 'three'
// 导入自定义的Entity基类
import Entity from './Entity'
// 创建球体几何体(半径0.3,经纬分段数各20)
const GEOMETRY = new SphereGeometry(0.3, 20, 20)
// 创建标准材质并设置基础颜色为紫色(#614bdd)
const MATERIAL = new MeshStandardMaterial({
color: 0x614bdd,
})
// 定义Candy类,继承自Entity基类
export default class Candy extends Entity {
constructor(resolution, color) {
// 创建网格对象(使用共享的几何体和材质)
const mesh = new Mesh(GEOMETRY, MATERIAL)
// 调用父类Entity的构造函数
super(mesh, resolution) // 假设Entity处理了场景添加、位置初始化等
// 如果有传入颜色参数,则覆盖材质的默认颜色
// 注意:这里会修改共享材质,影响所有Candy实例!
if (color) {
MATERIAL.color.set(color)
}
// 生成随机点数(1-3点)
this.points = Math.floor(Math.random() * 3) + 1
// 根据点数设置缩放比例:点数越多,糖果越大
// 基础缩放0.5 + (点数*0.5)/3 → 范围在0.5~1.0之间
this.mesh.scale.setScalar(0.5 + (this.points * 0.5) / 3)
}
}
创建LinkedKList与ListNode类,实现链表结构,来模拟贪吃蛇相连的每节节点。
// 链表容器类(管理整个链)
export default class LinkedList {
constructor(head) { // 初始化必须传入头节点
this.head = head // 存储链表头部
this.end = head // 存储链表尾部(初始头就是尾)
}
// 添加新节点方法(注意:只能向后追加)
addNode(node) {
this.end.linkTo(node) // 让当前尾部连接新节点
this.end = node // 更新尾部为新节点
}
}
// 链表节点类(每个节点像一节火车车厢)
export default class ListNode {
next = null // 指向下一个车厢的钩子
prev = null // 指向前一个车厢的钩子
constructor(data) {
this.data = data // 当前车厢
}
// 连接节点方法(像车厢挂钩的动作)
linkTo(node) {
this.next = node // 当前节点钩住下一个
node.prev = this // 下一个节点反钩回来(形成双向连接)
}
}
Snake类
创建贪吃蛇本体,实现转向,移动,吃糖果加尾部节点,碰到障碍物检测的逻辑。需要用到之前的LinkedList、ListNode、Entity类。
import {
EventDispatcher,
Mesh,
MeshNormalMaterial,
MeshStandardMaterial,
SphereGeometry,
Vector2,
Vector3,
} from 'three'
import LinkedList from './LinkedList'
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry'
import ListNode from './ListNode'
import Entity from './Entity'
const NODE_GEOMETRY = new RoundedBoxGeometry(0.9, 0.9, 0.9, 5, 0.1) // 用threejs内置函数创建圆角四方块
const NODE_MATERIAL = new MeshStandardMaterial({
color: 0xff470a,
}) // 创建材质并给初始颜色
const UP = new Vector3(0, 0, -1)
const DOWN = new Vector3(0, 0, 1)
const LEFT = new Vector3(-1, 0, 0)
const RIGHT = new Vector3(1, 0, 0)
export default class Snake extends EventDispatcher {
direction = RIGHT // 初始方向朝右
indexes = [] // 存储蛇身占用的格子坐标
speedInterval = 240 // 移动速度(数值越大越慢)
constructor({ scene, resolution = new Vector2(10, 10), color, mouthColor }) {
super()
// 把游戏场景、地图尺寸、颜色存起来
this.scene = scene
this.resolution = resolution
this.mouthColor = mouthColor
// 如果有颜色参数,给所有蛇节点刷颜色
if (color) {
NODE_MATERIAL.color.set(color)
}
this.init() // 开始组装蛇
}
get head() {
return this.body.head
}
get end() {
return this.body.end
}
//组装蛇头
createHeadMesh() {
const headMesh = this.body.head.data.mesh // 获取蛇头模型
// 造左眼:白眼球+黑眼珠
const leftEye = new Mesh(
new SphereGeometry(0.2, 10, 10),
new MeshStandardMaterial({ color: 0xffffff })
)
leftEye.scale.x = 0.1
leftEye.position.x = 0.5
leftEye.position.y = 0.12
leftEye.position.z = -0.1
let leftEyeHole = new Mesh(
new SphereGeometry(0.22, 10, 10),
new MeshStandardMaterial({ color: 0x333333 })
)
leftEyeHole.scale.set(1, 0.6, 0.6)
leftEyeHole.position.x += 0.05
leftEye.add(leftEyeHole)
// 造右眼:同上
const rightEye = leftEye.clone()
rightEye.position.x = -0.5
rightEye.rotation.y = Math.PI
// 造嘴巴:紫色圆角矩形
const mouthMesh = new Mesh(
new RoundedBoxGeometry(1.05, 0.1, 0.6, 5, 0.1),
new MeshStandardMaterial({
color: this.mouthColor, //0x614bdd,
})
)
mouthMesh.rotation.x = -Math.PI * 0.07
mouthMesh.position.z = 0.23
mouthMesh.position.y = -0.19
this.mouth = mouthMesh
// 把零件装到蛇头上
headMesh.add(rightEye, leftEye, mouthMesh)
/* 调整头部朝向当前方向 */
headMesh.lookAt(headMesh.position.clone().add(this.direction))
}
init() {
// 重置方向
this.direction = RIGHT
this.iMoving = null
// 造第一个蛇头节点(放在地图中心)
const head = new ListNode(new SnakeNode(this.resolution))
head.data.mesh.position.x = this.resolution.x / 2
head.data.mesh.position.z = this.resolution.y / 2
this.body = new LinkedList(head)
this.createHeadMesh()
this.indexes.push(this.head.data.getIndexByCoord())
// 添加初始三节身体(类似火车车厢)
for (let i = 0; i < 3; i++) {
const position = this.end.data.mesh.position.clone()
position.sub(this.direction) // 每节身体往后挪一格
this.addTailNode() // 挂载到链表末尾
this.end.data.mesh.position.copy(position)
this.indexes.push(this.end.data.getIndexByCoord())
}
head.data.in()
this.scene.add(head.data.mesh)
}
setDirection(keyCode) {
let newDirection
// 根据按键计算新方向(上下左右)
switch (keyCode) {
case 'ArrowUp':
case 'KeyW':
newDirection = UP
break
case 'ArrowDown':
case 'KeyS':
newDirection = DOWN
break
case 'ArrowLeft':
case 'KeyA':
newDirection = LEFT
break
case 'ArrowRight':
case 'KeyD':
newDirection = RIGHT
break
default:
return
}
const dot = this.direction.dot(newDirection)
// 禁止180度掉头(比如正在右走不能直接左转)
if (dot === 0) {
this.newDirection = newDirection
}
}
// 每帧判断
update() {
// 应用新方向
if (this.newDirection) {
this.direction = this.newDirection
this.newDirection = null
}
let currentNode = this.end
// 处理吃糖果后的尾巴生长
if (this.end.data.candy) {
this.end.data.candy = null
this.end.data.mesh.scale.setScalar(1)
this.addTailNode()
}
// 身体像波浪一样跟随头部移动
while (currentNode.prev) {
const candy = currentNode.prev.data.candy
if (candy) {
currentNode.data.candy = candy
currentNode.data.mesh.scale.setScalar(1.15)
currentNode.prev.data.candy = null
currentNode.prev.data.mesh.scale.setScalar(1)
}
const position = currentNode.prev.data.mesh.position
// 每节身体移动到前一节的位置
currentNode.data.mesh.position.copy(position)
currentNode = currentNode.prev // 往前遍历
}
const headPos = currentNode.data.mesh.position
headPos.add(this.direction)
// currentNode.data.mesh.position.add(this.direction)
const headMesh = this.body.head.data.mesh
headMesh.lookAt(headMesh.position.clone().add(this.direction))
// 移动头部
if (headPos.z < 0) {
headPos.z = this.resolution.y - 1
} else if (headPos.z > this.resolution.y - 1) {
headPos.z = 0
}
// 边界穿越处理(从地图左边消失就从地图右边出来...其他边界类似)
if (headPos.x < 0) {
headPos.x = this.resolution.x - 1
} else if (headPos.x > this.resolution.x - 1) {
headPos.x = 0
}
this.updateIndexes()
// 向上抛出事件
this.dispatchEvent({ type: 'updated' })
}
// 死亡
die() {
let node = this.body.head
// 移除所有身体部件
do {
this.scene.remove(node.data.mesh) // 从场景删除模型
node = node.next
} while (node)
this.init() // 重新初始化
this.addEventListener({ type: 'die' }) // 向上抛出死亡事件
}
checkSelfCollision() {
// 检查头部坐标是否和身体坐标重叠
const headIndex = this.indexes.pop()
const collide = this.indexes.includes(headIndex)
this.indexes.push(headIndex)
return collide // 撞到自己返回true
}
checkEntitiesCollision(entities) {
// 检查头部坐标是否和障碍物坐标重叠
const headIndex = this.indexes.at(-1)
const entity = entities.find(
(entity) => entity.getIndexByCoord() === headIndex
)
return !!entity // 撞到障碍物返回true
}
// 更新蛇身所有节点的网格坐标索引,用于碰撞检测等需要知道蛇身位置的功能
updateIndexes() {
// 清空旧索引(相当于擦除之前的身体痕迹)
this.indexes = []
// 从蛇尾开始遍历(相当于从火车最后一节车厢开始检查)
let node = this.body.end
// 循环向前遍历所有节点(直到没有前一节车厢)
while (node) {
// 获取当前节点的网格坐标(比如把3D坐标转换为地图网格的[x,y])
// 假设地图是10x10网格,坐标(5.3, 0, 5.7)会被转换为索引[5,5]
// 将索引推入数组(记录这节身体占据的格子)
this.indexes.push(node.data.getIndexByCoord())
// 移动到前一节身体(向蛇头方向移动)
node = node.prev
// 最终得到的indexes数组示例:
// [[3,5], [4,5], [5,5]] 表示蛇身占据这三个网格
// 其中最后一个元素[5,5]是蛇头位置
}
}
// 添加尾部节点
addTailNode(position) {
const node = new ListNode(new SnakeNode(this.resolution))
if (position) {
node.data.mesh.position.copy(position)
} else {
node.data.mesh.position.copy(this.end.data.mesh.position)
}
this.body.addNode(node)
node.data.in()
this.scene.add(node.data.mesh)
}
}
// 蛇身体节点类
class SnakeNode extends Entity {
constructor(resolution) {
const mesh = new Mesh(NODE_GEOMETRY, NODE_MATERIAL)
super(mesh, resolution)
}
}
实现障碍物,树Tree与石头Rock的类
// 导入Three.js相关模块
import {
IcosahedronGeometry, // 二十面体几何体(适合制作复杂形状)
Mesh, // 网格对象(用于组合几何体与材质)
MeshNormalMaterial, // 法线材质(调试用,显示表面朝向)
MeshStandardMaterial, // PBR标准材质(支持金属/粗糙度等特性)
} from 'three'
import Entity from './Entity' // 基础实体类
// 创建共享几何体(优化性能,所有树实例共用同一个几何体)
const GEOMETRY = new IcosahedronGeometry(0.3) // 基础半径为0.3的二十面体
GEOMETRY.rotateX(Math.random() * Math.PI * 2) // 随机绕X轴旋转(避免重复感)
GEOMETRY.scale(1, 6, 1) // Y轴拉伸6倍,形成细长形状(类似树干)
// 创建共享材质(所有树实例默认使用相同材质)
const MATERIAL = new MeshStandardMaterial({
flatShading: true, // 平面着色(增强低多边形风格)
color: 0xa2d109, // 默认黄绿色(类似树叶颜色)
})
// 定义树类(继承自基础实体类)
export default class Tree extends Entity {
constructor(resolution, color) {
// 创建网格实例(组合几何体与材质)
const mesh = new Mesh(GEOMETRY, MATERIAL)
// 随机缩放(0.6~1.2倍原始尺寸,制造大小差异)
mesh.scale.setScalar(0.6 + Math.random() * 0.6)
// 随机Y轴旋转(让树木朝向不同方向)
mesh.rotation.y = Math.random() * Math.PI * 2
// 如果指定颜色,覆盖默认材质颜色
if (color) {
MATERIAL.color.set(color)
}
// 调用父类构造函数(处理坐标转换等)
super(mesh, resolution)
}
}
// 导入Three.js相关模块
import {
IcosahedronGeometry, // 二十面体几何体(用于创建复杂形状)
Mesh, // 网格对象(组合几何体与材质)
MeshNormalMaterial, // 法线材质(调试用)
MeshStandardMaterial, // PBR标准材质(支持金属/粗糙度)
} from 'three'
import Entity from './Entity' // 基础实体类
// 创建共享几何体(所有岩石实例共用)
const GEOMETRY = new IcosahedronGeometry(0.5) // 基础半径0.5的二十面体
// 创建共享材质(所有岩石实例默认使用相同材质)
const MATERIAL = new MeshStandardMaterial({
color: 0xacacac, // 默认岩石灰色
flatShading: true, // 平面着色(增强低多边形风格)
})
// 岩石类(继承基础实体类)
export default class Rock extends Entity {
constructor(resolution, color) {
// 创建网格实例(几何体+材质)
const mesh = new Mesh(GEOMETRY, MATERIAL)
// X轴:0.5~1倍随机缩放(横向随机宽度)
// Y轴:0.5~2.4倍缩放(使用平方让更多岩石较矮)
// Z轴:保持1倍(前后方向不变形)
mesh.scale.set(
Math.random() * 0.5 + 0.5,
0.5 + Math.random() ** 2 * 1.9,
1
)
mesh.rotation.y = Math.random() * Math.PI * 2 // 随机Y轴旋转(0-360度)
mesh.rotation.x = Math.random() * Math.PI * 0.1 // 轻微X轴倾斜(最大18度)
mesh.rotation.order = 'YXZ' // 旋转顺序:先Y后X最后Z(避免万向锁问题)
// 下沉位置(使岩石看起来半埋在地面)
mesh.position.y = -0.5
// 如果指定颜色,覆盖默认材质颜色
if (color) {
MATERIAL.color.set(color)
}
// 调用父类构造函数(处理坐标转换等)
super(mesh, resolution)
}
}
最后,实现地图尺寸配置Params.js,光照Lights等环境变量
// 导入Three.js二维向量模块
import { Vector2 } from 'three'
// 定义场景地图尺寸参数 20*20的格子
const resolution = new Vector2(20, 20)
/**
* 参数说明:
* x: 场景横向尺
* y: 场景纵向尺寸
*/
// 定义颜色配置集合
const colors = {
groundColor: '#ff7438', // 地面基础色(暖橙色)
fogColor: '#d68a4c' // 雾效颜色(与地面色协调的浅棕色)
}
// 导出配置参数(供其他模块统一访问)
export { resolution, colors }
// 导入Three.js光源相关模块
import { AmbientLight, DirectionalLight } from 'three'
import { resolution } from './Params' // 场景尺寸参数
// 创建环境光(提供基础照明,无方向性)
const ambLight = new AmbientLight(
0xffffff, // 白光(十六进制颜色值)
0.6 // 光照强度(范围0-1,相当于60%亮度)
)
// 创建平行光(模拟太阳光,产生方向性阴影)
const dirLight = new DirectionalLight(
0xffffff, // 白光
0.7 // 主光源强度(70%亮度)
)
// 设置平行光参数
dirLight.position.set(20, 20, 18) // 光源三维坐标(模拟高空太阳位置)
dirLight.target.position.set( // 光照焦点位置(场景中心点)
resolution.x / 2, // X轴中心(地图宽度的一半)
0, // Y轴保持地面高度
resolution.y / 2 // Z轴中心(地图深度的一半)
)
// 阴影质量配置
dirLight.shadow.mapSize.set(1024, 1024) // 阴影贴图分辨率(值越大越清晰,但更耗性能)
dirLight.shadow.radius = 7 // 阴影模糊半径(软化阴影边缘)
dirLight.shadow.blurSamples = 20 // 模糊采样次数(提升阴影边缘平滑度)
// 设置阴影相机的视锥范围(控制产生阴影的区域)
dirLight.shadow.camera.top = 30 // 可见区域顶部边界
dirLight.shadow.camera.bottom = -30 // 可见区域底部边界
dirLight.shadow.camera.left = -30 // 可见区域左边界
dirLight.shadow.camera.right = 30 // 可见区域右边界
dirLight.castShadow = true // 启用该光源的阴影投射
// 组合光源(通常场景需要多个光源配合)
const lights = [dirLight, ambLight]
export default lights // 导出光源配置集合
最后是入口文件main.js的主逻辑,包含游戏循环,按键监听,主题切换,游戏开始与失败逻辑
import './style.css'
// 导入Three.js核心库和扩展模块
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' // 摄像机轨道控制器
import Snake from './Snake' // 贪吃蛇游戏角色
import Candy from './Candy' // 糖果道具
import Rock from './Rock' // 岩石障碍物
import Tree from './Tree' // 树木装饰
import lights from './Lights' // 光照系统
import { resolution } from './Params' // 场景分辨率参数
import gsap from 'gsap' // 动画库(用于平滑过渡)
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader' // 字体加载器
import fontSrc from 'three/examples/fonts/helvetiker_bold.typeface.json?url' // 3D字体文件
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry' // 3D文字几何体
import Entity from './Entity' // 基础实体类
// 设备检测(移动端适配)
const isMobile = window.innerWidth <= 768
const loader = new FontLoader()
let font
// 字体加载系统
loader.load(fontSrc, function (loadedFont) {
font = loadedFont
printScore() // 字体加载完成后初始化计分板
})
// 配色方案集合(支持主题切换)
const palettes = {
green: { // 绿色主题
groundColor: 0x56f854,
fogColor: 0x39c09f,
rockColor: 0xebebeb,
treeColor: 0x639541,
candyColor: 0x1d5846,
snakeColor: 0x1d5846,
mouthColor: 0x39c09f,
},
orange: { // 橙色主题
groundColor: 0xd68a4c,
fogColor: 0xffac38,
rockColor: 0xacacac,
treeColor: 0xa2d109,
candyColor: 0x614bdd,
snakeColor: 0xff470a,
mouthColor: 0x614bdd,
},
lilac: { // 紫色主题
groundColor: 0xd199ff,
fogColor: 0xb04ce6,
rockColor: 0xebebeb,
treeColor: 0x53d0c1,
candyColor: 0x9900ff,
snakeColor: 0xff2ed2,
mouthColor: 0x614bdd,
},
}
// 主题管理系统
let paletteName = localStorage.getItem('paletteName') || 'green'
let selectedPalette = palettes[paletteName]
const params = {
...selectedPalette, // 当前生效的配色参数
}
// 应用配色方案(支持运行时切换)
function applyPalette(paletteName) {
const palette = palettes[paletteName]
localStorage.setItem('paletteName', paletteName)
selectedPalette = palette
if (!palette) return
// 更新场景元素颜色
const {
groundColor,
fogColor,
rockColor,
treeColor,
candyColor,
snakeColor,
mouthColor,
} = palette
planeMaterial.color.set(groundColor) // 地面
scene.fog.color.set(fogColor) // 雾效
scene.background.set(fogColor) // 背景
// 更新实体颜色(岩石、树木等)
entities
.find((entity) => entity instanceof Rock)
?.mesh.material.color.set(rockColor)
entities
.find((entity) => entity instanceof Tree)
?.mesh.material.color.set(treeColor)
// 更新游戏元素
candies[0].mesh.material.color.set(candyColor)
snake.body.head.data.mesh.material.color.set(snakeColor)
snake.body.head.data.mesh.material.color.set(snakeColor)
snake.mouthColor = mouthColor
snake.mouth.material.color.set(mouthColor)
// 更新UI按钮
btnPlayImg.src = `/btn-play-bg-${paletteName}.png`
}
// 游戏核心参数
let score = 0 // 得分统计
// 网格辅助线(可视化场景坐标系)
const gridHelper = new THREE.GridHelper(
resolution.x, // 横向分割数
resolution.y, // 纵向分割数
0xffffff, // 主网格颜色
0xffffff // 次级网格颜色
)
gridHelper.position.set(resolution.x / 2 - 0.5, -0.49, resolution.y / 2 - 0.5) // 居中定位
gridHelper.material.transparent = true
gridHelper.material.opacity = isMobile ? 0.75 : 0.3 // 移动端降低透明度
// 场景初始化
const scene = new THREE.Scene()
scene.background = new THREE.Color(params.fogColor) // 背景
scene.fog = new THREE.Fog(params.fogColor, 5, 40) // 添加雾效
scene.add(gridHelper) // 添加辅助网格
// 视窗尺寸管理
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
}
// 摄像机系统
const fov = 60 // 正交相机(类似人眼)
const camera = new THREE.PerspectiveCamera(fov, sizes.width / sizes.height, 0.1)
// 摄像机位置(移动端与PC不同)
const finalPosition = isMobile
? new THREE.Vector3(resolution.x / 2 - 0.5, resolution.x + 15, resolution.y)
: new THREE.Vector3(
-8 + resolution.x / 2,
resolution.x / 2 + 4,
resolution.y + 6
)
const initialPosition = new THREE.Vector3(
resolution.x / 2 + 5,
4,
resolution.y / 2 + 4
)
camera.position.copy(initialPosition)
// 渲染器配置
const renderer = new THREE.WebGLRenderer({
antialias: window.devicePixelRatio < 2, // 抗锯齿(高清屏关闭)
logarithmicDepthBuffer: true, // 解决远距离渲染问题
})
document.body.appendChild(renderer.domElement)
handleResize() // 初始自适应
// 高级渲染特性
renderer.toneMapping = THREE.ACESFilmicToneMapping // 电影级色调映射
renderer.toneMappingExposure = 1.2 // 曝光强度
renderer.shadowMap.enabled = true // 启用阴影
renderer.shadowMap.type = THREE.VSMShadowMap // 柔和阴影算法
// 摄像机控制器(限制移动方式)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 惯性滑动
controls.enableZoom = false // 禁用缩放
controls.enablePan = false // 禁用平移
controls.enableRotate = false // 禁用旋转
controls.target.set(resolution.x/2 -2, 0, resolution.y/2 + (isMobile ? 0 : 2))
// 地面
const planeGeometry = new THREE.PlaneGeometry(resolution.x*50, resolution.y*50)
planeGeometry.rotateX(-Math.PI * 0.5) // 创建水平面为地面
const planeMaterial = new THREE.MeshStandardMaterial({
color: params.groundColor
})
const plane = new THREE.Mesh(planeGeometry, planeMaterial)
plane.position.set(resolution.x/2-0.5, -0.5, resolution.y/2-0.5) // 对齐网格
plane.receiveShadow = true // 接收阴影
scene.add(plane)
// 游戏角色初始化
const snake = new Snake({
scene,
resolution,
color: selectedPalette.snakeColor,
mouthColor: selectedPalette.mouthColor,
})
// 游戏事件系统
snake.addEventListener('updated', function () {
// 碰撞检测,碰撞上障碍就死亡,重置游戏
if (snake.checkSelfCollision() || snake.checkEntitiesCollision(entities)) {
snake.die()
resetGame()
}
// 糖果收集检测
const headIndex = snake.indexes.at(-1)
const candyIndex = candies.findIndex(
(candy) => candy.getIndexByCoord() === headIndex
)
// 吃到糖果
if (candyIndex >= 0) {
const candy = candies[candyIndex]
scene.remove(candy.mesh) // 从场景移除被吃掉的糖果
candies.splice(candyIndex, 1)
snake.body.head.data.candy = candy
addCandy() // 生成新的糖果
score += candy.points // 加分
printScore() // 更新分数板
}
})
let scoreEntity // 当前分数板
function printScore() {
// 等待字体加载完成
if (!font) {
return
}
if (!score) {
score = 0
}
// 清理旧分数对象(避免内存泄漏)
if (scoreEntity) {
scene.remove(scoreEntity.mesh)
scoreEntity.mesh.geometry.dispose() // 释放几何体内存
scoreEntity.mesh.material.dispose() // 释放材质内存
}
// 创建带倒角的3D文字几何体
const geometry = new TextGeometry(`${score}`, {
font: font,
size: 1,
depth: 0.1,
curveSegments: 12,
bevelEnabled: true, // 启用倒角
bevelThickness: 0.1, // 倒角深度
bevelSize: 0.1, // 倒角宽度
bevelOffset: 0,
bevelSegments: 5,
})
geometry.center() // 几何体居中
if (isMobile) {
geometry.rotateX(-Math.PI * 0.5) // 移动端纵向旋转90度
}
// 复用蛇头材质创建网格
const mesh = new THREE.Mesh(geometry, snake.body.head.data.mesh.material)
// 定位在场景上方
mesh.position.x = resolution.x / 2 - 0.5
mesh.position.z = -4
mesh.position.y = 1.8
// 启用阴影投射
mesh.castShadow = true
// 创建分数实体并加入场景
scoreEntity = new Entity(mesh, resolution, { size: 0.8, number: 0.3 })
// 播放入场动画
scoreEntity.in()
scene.add(scoreEntity.mesh)
}
// 移动端触摸控制系统 虚拟方向键DOM
const mobileArrows = document.getElementById('mobile-arrows')
function registerEventListener() {
if (isMobile) {
// 触摸逻辑核心参数
const prevTouch = new THREE.Vector2() // 记录上次触摸位置
let middle = 1.55 // 屏幕中心参考线
let scale = 1 // 方向判断灵敏度系数
// 触摸事件监听
window.addEventListener('touchstart', (event) => {
const touch = event.targetTouches[0]
middle = THREE.MathUtils.clamp(middle, 1.45, 1.65)
// 将屏幕坐标转换为归一化坐标(-1到1范围)
let x = (2 * touch.clientX) / window.innerWidth - 1
let y = (2 * touch.clientY) / window.innerHeight - middle
// 游戏启动检测
if (!isRunning) startGame()
// 方向判断算法(根据坐标象限)
if (x * scale > y) {
if (x * scale < -y) {
snake.setDirection('ArrowUp')
scale = 3
} else {
snake.setDirection('ArrowRight')
middle += y
scale = 0.33
}
} else {
if (-x * scale > y) {
snake.setDirection('ArrowLeft')
middle += y
scale = 0.33
} else {
snake.setDirection('ArrowDown')
scale = 3
}
}
prevTouch.x = x
prevTouch.y = y // 记录本次触摸位置
})
} else {
// 桌面端键盘事件监听
window.addEventListener('keydown', function (e) {
const keyCode = e.code
snake.setDirection(keyCode) // 方向键控制
if (keyCode === 'Space') { // 空格键暂停/继续
!isRunning ? startGame() : stopGame()
} else if (!isRunning) {
startGame()
}
})
}
}
let isRunning // 游戏运行状态标记
function startGame() {
if (!snake.isMoving) {
// 设置240ms间隔的蛇移动循环(约4帧/秒)
isRunning = setInterval(() => {
snake.update()
}, 240)
}
}
// 暂停游戏
function stopGame() {
clearInterval(isRunning) // 清除计时器
isRunning = null // 重置状态
}
// 重置游戏
function resetGame() {
stopGame() // 停止游戏循环
score = 0 // 重置积分
// 清理所有糖果对象
let candy = candies.pop()
while (candy) {
scene.remove(candy.mesh)
candy = candies.pop()
}
// 清理所有实体对象(岩石/树木)
let entity = entities.pop()
while (entity) {
scene.remove(entity.mesh)
entity = entities.pop()
}
// 重新初始化游戏元素
addCandy()
generateEntities()
}
const candies = [] // 糖果对象池
const entities = [] // 实体对象池(障碍物)
// 糖果生成逻辑
function addCandy() {
const candy = new Candy(resolution, selectedPalette.candyColor)
let index = getFreeIndex() // 获取可用位置索引
// 设置三维坐标(基于索引的网格布局)
candy.mesh.position.x = index % resolution.x
candy.mesh.position.z = Math.floor(index / resolution.x)
candies.push(candy) // 加入对象池
candy.in() // 播放生成动画
scene.add(candy.mesh)
}
addCandy()
// 随机位置生成算法
function getFreeIndex() {
let index
let candyIndexes = candies.map((candy) => candy.getIndexByCoord())
let entityIndexes = entities.map((entity) => entity.getIndexByCoord())
// 生成随机索引(范围:0到场景总网格数)
do {
index = Math.floor(Math.random() * resolution.x * resolution.y)
} while (
snake.indexes.includes(index) || // 不与蛇身重叠
candyIndexes.includes(index) || // 不与现有糖果重叠
entityIndexes.includes(index) // 不与障碍物重叠
)
return index
}
// 障碍物生成逻辑
function addEntity() {
// 随机生成岩石或树木(50%概率)
const entity =
Math.random() > 0.5
? new Rock(resolution, selectedPalette.rockColor)
: new Tree(resolution, selectedPalette.treeColor)
let index = getFreeIndex() // 获取安全位置
// 设置坐标(与糖果生成逻辑相同)
entity.mesh.position.x = index % resolution.x
entity.mesh.position.z = Math.floor(index / resolution.x)
entities.push(entity) // 加入对象池
scene.add(entity.mesh) // 加入场景
}
// 初始实体生成器
function generateEntities() {
// 生成20个障碍物
for (let i = 0; i < 20; i++) {
addEntity()
}
// 按距离场景中心排序(优化渲染顺序)
entities.sort((a, b) => {
const c = new THREE.Vector3(
resolution.x / 2 - 0.5,
0,
resolution.y / 2 - 0.5
)
const distanceA = a.position.clone().sub(c).length()
const distanceB = b.position.clone().sub(c).length()
return distanceA - distanceB
})
// 使用GSAP实现弹性入场动画
gsap.from(
entities.map((entity) => entity.mesh.scale),
{
x: 0,
y: 0,
z: 0,
duration: 1,
ease: 'elastic.out(1.5, 0.5)',
stagger: {
grid: [20, 20],
amount: 0.7,
},
}
)
}
generateEntities()
// 添加光照
scene.add(...lights)
// 生成装饰性树,地图外的
const treeData = [
new THREE.Vector4(-5, 0, 10, 1),
new THREE.Vector4(-6, 0, 15, 1.2),
new THREE.Vector4(-5, 0, 16, 0.8),
new THREE.Vector4(-10, 0, 4, 1.3),
new THREE.Vector4(-5, 0, -3, 2),
new THREE.Vector4(-4, 0, -4, 1.5),
new THREE.Vector4(-2, 0, -15, 1),
new THREE.Vector4(5, 0, -20, 1.2),
new THREE.Vector4(24, 0, -12, 1.2),
new THREE.Vector4(2, 0, -6, 1.2),
new THREE.Vector4(3, 0, -7, 1.8),
new THREE.Vector4(1, 0, -9, 1.0),
new THREE.Vector4(15, 0, -8, 1.8),
new THREE.Vector4(17, 0, -9, 1.1),
new THREE.Vector4(18, 0, -7, 1.3),
new THREE.Vector4(24, 0, -1, 1.3),
new THREE.Vector4(26, 0, 0, 1.8),
new THREE.Vector4(32, 0, 0, 1),
new THREE.Vector4(28, 0, 6, 1.7),
new THREE.Vector4(24, 0, 15, 1.1),
new THREE.Vector4(16, 0, 23, 1.1),
new THREE.Vector4(12, 0, 24, 0.9),
new THREE.Vector4(-13, 0, -13, 0.7),
new THREE.Vector4(35, 0, 10, 0.7),
]
const tree = new Tree(resolution)
treeData.forEach(({ x, y, z, w }) => {
let clone = tree.mesh.clone()
clone.position.set(x, y, z)
clone.scale.setScalar(w)
scene.add(clone)
})
const rock = new Rock(resolution)
const resX = resolution.x
const rexY = resolution.y
// 生成装饰性石头,地图外的
const rockData = [
[new THREE.Vector3(-7, -0.5, 2), new THREE.Vector4(2, 8, 3, 2.8)],
[new THREE.Vector3(-3, -0.5, -10), new THREE.Vector4(3, 2, 2.5, 1.5)],
[new THREE.Vector3(-5, -0.5, 3), new THREE.Vector4(1, 1.5, 2, 0.8)],
[new THREE.Vector3(resX + 5, -0.5, 3), new THREE.Vector4(4, 1, 3, 1)],
[new THREE.Vector3(resX + 4, -0.5, 2), new THREE.Vector4(2, 2, 1, 1)],
[new THREE.Vector3(resX + 8, -0.5, 16), new THREE.Vector4(6, 2, 4, 4)],
[new THREE.Vector3(resX + 6, -0.5, 13), new THREE.Vector4(3, 2, 2.5, 3.2)],
[new THREE.Vector3(resX + 5, -0.5, -8), new THREE.Vector4(1, 1, 1, 0)],
[new THREE.Vector3(resX + 6, -0.5, -7), new THREE.Vector4(2, 4, 1.5, 0.5)],
[new THREE.Vector3(-5, -0.5, 14), new THREE.Vector4(1, 3, 2, 0)],
[new THREE.Vector3(-4, -0.5, 15), new THREE.Vector4(0.8, 0.6, 0.7, 0)],
[
new THREE.Vector3(resX / 2 + 5, -0.5, 25),
new THREE.Vector4(2.5, 0.8, 4, 2),
],
[
new THREE.Vector3(resX / 2 + 9, -0.5, 22),
new THREE.Vector4(1.2, 2, 1.2, 1),
],
[
new THREE.Vector3(resX / 2 + 8, -0.5, 21.5),
new THREE.Vector4(0.8, 1, 0.8, 2),
],
]
rockData.forEach(([position, { x, y, z, w }]) => {
let clone = new Rock(resolution).mesh
clone.position.copy(position)
clone.scale.set(x, y, z)
clone.rotation.y = w
scene.add(clone)
})
// 音效
const audio = document.getElementById('audio')
const btnVolume = document.getElementById('btn-volume')
const btnPlay = document.getElementById('btn-play')
const btnPlayImg = document.getElementById('btn-play-img')
// 音效按钮效果
gsap.fromTo(
btnPlay,
{ autoAlpha: 0, scale: 0, yPercent: -50, xPercent: -50 },
{
duration: 0.8,
autoAlpha: 1,
scale: 1,
yPercent: -50,
xPercent: -50,
delay: 0.3,
ease: `elastic.out(1.2, 0.7)`,
}
)
// 开始游戏
btnPlay.addEventListener('click', function () {
audio.play()
gsap.to(camera.position, { ...finalPosition, duration: 2 })
if (isMobile) {
gsap.to(controls.target, {
x: resolution.x / 2 - 0.5,
y: 0,
z: resolution.y / 2 - 0.5,
})
}
gsap.to(scene.fog, { duration: 2, near: isMobile ? 30 : 20, far: 55 })
gsap.to(this, {
duration: 1,
scale: 0,
ease: `elastic.in(1.2, 0.7)`,
onComplete: () => {
this.style.visibility = 'hidden'
},
})
registerEventListener()
})
const userVolume = localStorage.getItem('volume')
if (userVolume === 'off') {
muteVolume()
}
// 音量
const initialVolume = audio.volume
btnVolume.addEventListener('click', function () {
if (audio.volume === 0) {
unmuteVolume()
} else {
muteVolume()
}
})
// 静音
function muteVolume() {
localStorage.setItem('volume', 'off')
gsap.to(audio, { volume: 0, duration: 1 })
btnVolume.classList.remove('after:hidden')
btnVolume.querySelector(':first-child').classList.remove('animate-ping')
btnVolume.classList.add('after:block')
}
// 解除静音
function unmuteVolume() {
localStorage.setItem('volume', 'on')
btnVolume.classList.add('after:hidden')
btnVolume.querySelector(':first-child').classList.add('animate-ping')
btnVolume.classList.remove('after:block')
gsap.to(audio, { volume: initialVolume, duration: 1 })
}
// 主题选择
const topBar = document.querySelector('.top-bar')
const paletteSelectors = document.querySelectorAll('[data-color]')
gsap.to(topBar, {
opacity: 1,
delay: 0.5,
onComplete: () => {
gsap.to(paletteSelectors, {
duration: 1,
x: 0,
autoAlpha: 1,
ease: `elastic.out(1.2, 0.9)`,
stagger: {
amount: 0.2,
},
})
},
})
paletteSelectors.forEach((selector) =>
selector.addEventListener('click', function () {
const paletteName = this.dataset.color
applyPalette(paletteName)
})
)
// 加载器
const manager = new THREE.LoadingManager()
const textureLoader = new THREE.TextureLoader(manager)
// 按键新手引导
const wasd = textureLoader.load('/wasd.png')
const arrows = textureLoader.load('/arrows.png')
const wasdGeometry = new THREE.PlaneGeometry(3.5, 2)
wasdGeometry.rotateX(-Math.PI * 0.5)
const planeWasd = new THREE.Mesh(
wasdGeometry,
new THREE.MeshStandardMaterial({
transparent: true,
map: wasd,
opacity: isMobile ? 0 : 0.5,
})
)
const planeArrows = new THREE.Mesh(
wasdGeometry,
new THREE.MeshStandardMaterial({
transparent: true,
map: arrows,
opacity: isMobile ? 0 : 0.5,
})
)
planeArrows.position.set(8.7, 0, 21)
planeWasd.position.set(13, 0, 21)
// 添加按键新手引导
scene.add(planeArrows, planeWasd)
// 使用主题
applyPalette(paletteName)
// 游戏主循环
function tic() {
controls.update()
renderer.render(scene, camera)
requestAnimationFrame(tic)
}
requestAnimationFrame(tic)
// 监听屏幕尺寸变化
window.addEventListener('resize', handleResize)
function handleResize() {
sizes.width = window.innerWidth
sizes.height = window.innerHeight
camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()
renderer.setSize(sizes.width, sizes.height)
const pixelRatio = Math.min(window.devicePixelRatio, 2)
renderer.setPixelRatio(pixelRatio)
}

游戏 截图如上
游戏源码地址:GitCode - 全球开发者的开源社区,开源代码托管平台
游戏预览地址:3D贪吃蛇
创作不易,点个赞再走吧